diff --git a/.cargo/config.toml b/.cargo/config.toml index 717c5e18c8d294bacf65207bc6b8ecb7dba1b152..8db58d238003c29df6dbc9fa733c6d5521340103 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -19,6 +19,8 @@ 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 index bd742b33cdf5553346688c93580d3f5b0410216c..982542ca397e072d83af67608ea31a3415360a8e 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -33,7 +33,6 @@ workspace-members = [ "zed_emmet", "zed_glsl", "zed_html", - "perplexity", "zed_proto", "zed_ruff", "slash_commands_example", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39036ef5649e699ffda1636f304629fce6184371..ea352a9320827e25cfbf4f94dfcb28bdd9fba0d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 RUST_BACKTRACE: 1 + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} jobs: job_spec: @@ -52,9 +55,10 @@ jobs: fi # Specify anything which should skip full CI in this regex: # - docs/ + # - script/update_top_ranking_issues/ # - .github/ISSUE_TEMPLATE/ # - .github/workflows/ (except .github/workflows/ci.yml) - SKIP_REGEX='^(docs/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))' + SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))' if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -vP "$SKIP_REGEX") ]]; then echo "run_tests=true" >> $GITHUB_OUTPUT else @@ -65,13 +69,13 @@ jobs: else echo "run_docs=false" >> $GITHUB_OUTPUT fi - if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^Cargo.lock') ]]; then + if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then echo "run_license=true" >> $GITHUB_OUTPUT else echo "run_license=false" >> $GITHUB_OUTPUT fi NIX_REGEX='^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' - if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep "$NIX_REGEX") ]]; then + if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P "$NIX_REGEX") ]]; then echo "run_nix=true" >> $GITHUB_OUTPUT else echo "run_nix=false" >> $GITHUB_OUTPUT @@ -390,7 +394,7 @@ jobs: windows_tests: timeout-minutes: 60 - name: (Windows) Run Tests + name: (Windows) Run Clippy and tests needs: [job_spec] if: | github.repository_owner == 'zed-industries' && @@ -411,11 +415,10 @@ jobs: with: clean: false - - name: Setup Cargo and Rustup + - name: Configure CI run: | - mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore - cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml - .\script\install-rustup.ps1 + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" - name: cargo clippy run: | @@ -430,18 +433,9 @@ jobs: - name: Limit target directory size run: ./script/clear-target-dir-if-larger-than.ps1 250 - # - name: Check dev drive space - # working-directory: ${{ env.ZED_WORKSPACE }} - # # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive. - # run: ./script/exit-ci-if-dev-drive-is-full.ps1 95 - - # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug. - name: Clean CI config file if: always() - run: | - if (Test-Path "${{ env.CARGO_HOME }}/config.toml") { - Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force - } + run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue tests_pass: name: Tests Pass @@ -502,9 +496,6 @@ jobs: 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 }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} steps: - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -587,10 +578,6 @@ jobs: startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') needs: [linux_tests] - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -644,10 +631,6 @@ jobs: startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') needs: [linux_tests] - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -700,16 +683,12 @@ jobs: || contains(github.event.pull_request.labels.*.name, 'run-bundling') needs: [linux_tests] name: Build Zed on FreeBSD - # env: - # MYTOKEN : ${{ secrets.MYTOKEN }} - # MYTOKEN2: "value2" 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 @@ -763,12 +742,63 @@ jobs: # 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: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} + 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: + clean: false + + - 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 + + - name: Build Zed installer + 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') + with: + name: ZedEditorUserSetup-x64-${{ github.event.pull_request.head.sha || github.sha }}.exe + path: ${{ env.SETUP_PATH }} + + - name: Upload Artifacts to release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + # Re-enable when we are ready to publish windows preview releases + if: false && ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview + with: + draft: true + prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} + files: ${{ env.SETUP_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + auto-release-preview: name: Auto release preview if: | 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, freebsd] + needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd] runs-on: - self-hosted - bundle diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index d9287cb0826815a4b60c72389b950156da43df99..1b9669c5d527f568ea8cc6b3918feae92d8b44e0 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -12,6 +12,9 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 RUST_BACKTRACE: 1 + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} jobs: style: @@ -51,6 +54,32 @@ jobs: - name: Run tests uses: ./.github/actions/run_tests + windows-tests: + timeout-minutes: 60 + name: Run tests on Windows + if: github.repository_owner == 'zed-industries' + runs-on: [self-hosted, Windows, X64] + 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: timeout-minutes: 60 name: Create a macOS bundle @@ -65,9 +94,6 @@ jobs: 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 }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} steps: - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -99,10 +125,6 @@ jobs: runs-on: - buildjet-16vcpu-ubuntu-2004 needs: tests - env: - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -138,10 +160,6 @@ jobs: runs-on: - buildjet-16vcpu-ubuntu-2204-arm needs: tests - env: - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -172,9 +190,6 @@ jobs: if: github.repository_owner == 'zed-industries' runs-on: github-8vcpu-ubuntu-2404 needs: tests - env: - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} name: Build Zed on FreeBSD # env: # MYTOKEN : ${{ secrets.MYTOKEN }} @@ -213,10 +228,49 @@ jobs: bundle-nix: name: Build and cache Nix package + if: false needs: tests secrets: inherit uses: ./.github/workflows/nix.yml + bundle-windows-x64: + timeout-minutes: 60 + name: Create a Windows installer + if: github.repository_owner == 'zed-industries' + runs-on: [self-hosted, Windows, X64] + 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: + 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: 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 if: github.repository_owner == 'zed-industries' @@ -225,6 +279,7 @@ jobs: - bundle-mac - bundle-linux-x86 - bundle-linux-arm + - bundle-windows-x64 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/.zed/debug.json b/.zed/debug.json index 49b8f1a7a697303c383332f4ed704c844df22132..6f4e936c80f966a5882d3dc2cbc6d53d03e877c8 100644 --- a/.zed/debug.json +++ b/.zed/debug.json @@ -5,9 +5,7 @@ "build": { "label": "Build Zed", "command": "cargo", - "args": [ - "build" - ] + "args": ["build"] } }, { @@ -16,9 +14,7 @@ "build": { "label": "Build Zed", "command": "cargo", - "args": [ - "build" - ] + "args": ["build"] } - }, + } ] diff --git a/.zed/settings.json b/.zed/settings.json index 1ef6bc28f7dffb3fd7b25489f3f6ff0c1b0f74c9..68e05a426f2474cb663aa5ff843905f375170e0f 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -40,7 +40,7 @@ }, "file_types": { "Dockerfile": ["Dockerfile*[!dockerignore]"], - "JSONC": ["assets/**/*.json", "renovate.json"], + "JSONC": ["**/assets/**/*.json", "renovate.json"], "Git Ignore": ["dockerignore"] }, "hard_tabs": false, diff --git a/Cargo.lock b/Cargo.lock index d22f1e6795983e9aadf2695f03c84899e31a2b3c..0a5a1a01fe69e250a10c0fd26867d69d0337336f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,34 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acp" +version = "0.1.0" +dependencies = [ + "agent_servers", + "agentic-coding-protocol", + "anyhow", + "assistant_tool", + "async-pipe", + "buffer_diff", + "editor", + "env_logger 0.11.8", + "futures 0.3.31", + "gpui", + "indoc", + "itertools 0.14.0", + "language", + "markdown", + "project", + "serde_json", + "settings", + "smol", + "tempfile", + "ui", + "util", + "workspace-hack", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -107,6 +135,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "agent_servers" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "futures 0.3.31", + "gpui", + "paths", + "project", + "schemars", + "serde", + "settings", + "util", + "which 6.0.3", + "workspace-hack", +] + [[package]] name = "agent_settings" version = "0.1.0" @@ -130,8 +176,11 @@ dependencies = [ name = "agent_ui" version = "0.1.0" dependencies = [ + "acp", "agent", + "agent_servers", "agent_settings", + "agentic-coding-protocol", "anyhow", "assistant_context", "assistant_slash_command", @@ -191,6 +240,7 @@ dependencies = [ "settings", "smol", "streaming_diff", + "task", "telemetry", "telemetry_events", "terminal", @@ -212,6 +262,24 @@ dependencies = [ "zed_llm_client", ] +[[package]] +name = "agentic-coding-protocol" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e276b798eddd02562a339340a96919d90bbfcf78de118fdddc932524646fac7" +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" @@ -538,6 +606,8 @@ dependencies = [ "anyhow", "futures 0.3.31", "gpui", + "net", + "parking_lot", "smol", "tempfile", "util", @@ -608,7 +678,7 @@ dependencies = [ "anyhow", "async-trait", "collections", - "derive_more", + "derive_more 0.99.19", "extension", "futures 0.3.31", "gpui", @@ -671,7 +741,7 @@ dependencies = [ "clock", "collections", "ctor", - "derive_more", + "derive_more 0.99.19", "futures 0.3.31", "gpui", "icons", @@ -707,7 +777,7 @@ dependencies = [ "clock", "collections", "component", - "derive_more", + "derive_more 0.99.19", "editor", "feature_flags", "fs", @@ -1164,7 +1234,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more", + "derive_more 0.99.19", "gpui", "parking_lot", "rodio", @@ -2857,7 +2927,7 @@ dependencies = [ "cocoa 0.26.0", "collections", "credentials_provider", - "derive_more", + "derive_more 0.99.19", "feature_flags", "fs", "futures 0.3.31", @@ -3041,10 +3111,11 @@ dependencies = [ "context_server", "ctor", "dap", + "dap-types", "dap_adapters", "dashmap 6.1.0", "debugger_ui", - "derive_more", + "derive_more 0.99.19", "editor", "envy", "extension", @@ -3249,7 +3320,7 @@ name = "command_palette_hooks" version = "0.1.0" dependencies = [ "collections", - "derive_more", + "derive_more 0.99.19", "gpui", "workspace-hack", ] @@ -4324,11 +4395,15 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "hex", + "indoc", "itertools 0.14.0", "language", "log", "menu", + "notifications", "parking_lot", + "parse_int", "paths", "picker", "pretty_assertions", @@ -4344,6 +4419,7 @@ dependencies = [ "tasks_ui", "telemetry", "terminal_view", + "text", "theme", "tree-sitter", "tree-sitter-go", @@ -4451,6 +4527,27 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "unicode-xid", +] + [[package]] name = "derive_refineable" version = "0.1.0" @@ -4830,6 +4927,7 @@ dependencies = [ "tree-sitter-python", "tree-sitter-rust", "tree-sitter-typescript", + "tree-sitter-yaml", "ui", "unicode-script", "unicode-segmentation", @@ -5186,6 +5284,16 @@ dependencies = [ "libc", ] +[[package]] +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", +] + [[package]] name = "exr" version = "1.73.0" @@ -6138,7 +6246,7 @@ dependencies = [ "askpass", "async-trait", "collections", - "derive_more", + "derive_more 0.99.19", "futures 0.3.31", "git2", "gpui", @@ -7155,7 +7263,7 @@ dependencies = [ "core-video", "cosmic-text", "ctor", - "derive_more", + "derive_more 0.99.19", "embed-resource", "env_logger 0.11.8", "etagere", @@ -7701,7 +7809,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes 1.10.1", - "derive_more", + "derive_more 0.99.19", "futures 0.3.31", "http 1.3.1", "log", @@ -8139,7 +8247,7 @@ dependencies = [ "async-trait", "cargo_metadata", "collections", - "derive_more", + "derive_more 0.99.19", "extension", "fs", "futures 0.3.31", @@ -8898,6 +9006,7 @@ dependencies = [ "gpui", "language", "lsp", + "project", "serde", "serde_json", "util", @@ -9022,7 +9131,6 @@ dependencies = [ "itertools 0.14.0", "language", "lsp", - "picker", "project", "release_channel", "serde_json", @@ -9577,12 +9685,11 @@ dependencies = [ [[package]] name = "lsp-types" version = "0.95.1" -source = "git+https://github.com/zed-industries/lsp-types?rev=c9c189f1c5dd53c624a419ce35bc77ad6a908d18#c9c189f1c5dd53c624a419ce35bc77ad6a908d18" +source = "git+https://github.com/zed-industries/lsp-types?rev=6add7052b598ea1f40f7e8913622c3958b009b60#6add7052b598ea1f40f7e8913622c3958b009b60" dependencies = [ "bitflags 1.3.2", "serde", "serde_json", - "serde_repr", "url", ] @@ -10229,6 +10336,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "net" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-io", + "smol", + "tempfile", + "windows 0.61.1", + "workspace-hack", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -11182,6 +11301,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse_int" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31" +dependencies = [ + "num-traits", +] + [[package]] name = "partial-json-fixer" version = "0.5.3" @@ -11334,14 +11462,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "perplexity" -version = "0.1.0" -dependencies = [ - "serde", - "zed_extension_api 0.6.0", -] - [[package]] name = "pest" version = "2.8.0" @@ -12233,6 +12353,7 @@ dependencies = [ "anyhow", "askpass", "async-trait", + "base64 0.22.1", "buffer_diff", "circular-buffer", "client", @@ -12278,6 +12399,7 @@ dependencies = [ "sha2", "shellexpand 2.1.2", "shlex", + "smallvec", "smol", "snippet", "snippet_provider", @@ -12533,6 +12655,7 @@ dependencies = [ "prost 0.9.0", "prost-build 0.9.0", "serde", + "typed-path", "workspace-hack", ] @@ -13196,6 +13319,7 @@ dependencies = [ "fs", "futures 0.3.31", "git", + "git2", "git_hosting_providers", "gpui", "gpui_tokio", @@ -14010,7 +14134,7 @@ dependencies = [ [[package]] name = "scap" version = "0.0.8" -source = "git+https://github.com/zed-industries/scap?rev=08f0a01417505cc0990b9931a37e5120db92e0d0#08f0a01417505cc0990b9931a37e5120db92e0d0" +source = "git+https://github.com/zed-industries/scap?rev=270538dc780f5240723233ff901e1054641ed318#270538dc780f5240723233ff901e1054641ed318" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -14057,10 +14181,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", "schemars_derive", + "semver", "serde", "serde_json", ] @@ -14582,15 +14708,19 @@ dependencies = [ "language", "log", "menu", + "notifications", "paths", "project", "schemars", "search", "serde", + "serde_json", "settings", "theme", "tree-sitter-json", + "tree-sitter-rust", "ui", + "ui_input", "util", "workspace", "workspace-hack", @@ -16018,7 +16148,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more", + "derive_more 0.99.19", "fs", "futures 0.3.31", "gpui", @@ -17033,6 +17163,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" + [[package]] name = "typeid" version = "1.0.3" @@ -17346,6 +17482,7 @@ dependencies = [ "rand 0.8.5", "regex", "rust-embed", + "schemars", "serde", "serde_json", "serde_json_lenient", @@ -18355,7 +18492,6 @@ dependencies = [ "language", "picker", "project", - "schemars", "serde", "settings", "telemetry", @@ -19550,6 +19686,8 @@ dependencies = [ "rustix 1.0.7", "rustls 0.23.26", "rustls-webpki 0.103.1", + "scap", + "schemars", "scopeguard", "sea-orm", "sea-query-binder", @@ -19596,7 +19734,9 @@ dependencies = [ "wasmtime-cranelift", "wasmtime-environ", "winapi", + "windows 0.61.1", "windows-core 0.61.0", + "windows-future", "windows-numerics", "windows-sys 0.48.0", "windows-sys 0.52.0", @@ -19943,10 +20083,11 @@ dependencies = [ [[package]] name = "zed" -version = "0.195.0" +version = "0.196.0" dependencies = [ "activity_indicator", "agent", + "agent_servers", "agent_settings", "agent_ui", "anyhow", @@ -20141,9 +20282,9 @@ dependencies = [ [[package]] name = "zed_llm_client" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c740e29260b8797ad252c202ea09a255b3cbc13f30faaf92fb6b2490336106e0" +checksum = "6607f74dee2a18a9ce0f091844944a0e59881359ab62e0768fb0618f55d4c1dc" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1d9cf31c1498df933c2e8134c97d6b96f0ded628..5403f279c80666f37e3d837e200ba0ba0b100a9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,11 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/acp", "crates/agent_ui", "crates/agent", "crates/agent_settings", + "crates/agent_servers", "crates/anthropic", "crates/askpass", "crates/assets", @@ -45,6 +47,7 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/editor", + "crates/explorer_command_injector", "crates/eval", "crates/extension", "crates/extension_api", @@ -99,6 +102,7 @@ members = [ "crates/migrator", "crates/mistral", "crates/multi_buffer", + "crates/net", "crates/node_runtime", "crates/notifications", "crates/ollama", @@ -188,7 +192,6 @@ members = [ "extensions/emmet", "extensions/glsl", "extensions/html", - "extensions/perplexity", "extensions/proto", "extensions/ruff", "extensions/slash-commands-example", @@ -215,10 +218,12 @@ edition = "2024" # Workspace member crates # -activity_indicator = { path = "crates/activity_indicator" } +acp = { path = "crates/acp" } agent = { path = "crates/agent" } +activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } +agent_servers = { path = "crates/agent_servers" } ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } @@ -276,7 +281,6 @@ go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } gpui = { path = "crates/gpui", default-features = false, features = [ "http_client", - "screen-capture", ] } gpui_macros = { path = "crates/gpui_macros" } gpui_tokio = { path = "crates/gpui_tokio" } @@ -312,6 +316,7 @@ menu = { path = "crates/menu" } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } +net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } @@ -399,6 +404,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # +agentic-coding-protocol = { version = "0.0.9" } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" @@ -486,7 +492,7 @@ 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 = "c9c189f1c5dd53c624a419ce35bc77ad6a908d18" } +lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "6add7052b598ea1f40f7e8913622c3958b009b60" } markup5ever_rcdom = "0.3.0" metal = "0.29" moka = { version = "0.12.10", features = ["sync"] } @@ -501,6 +507,7 @@ 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" 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" } @@ -540,7 +547,7 @@ 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 = "08f0a01417505cc0990b9931a37e5120db92e0d0", default-features = false } +scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false } schemars = { version = "1.0", features = ["indexmap2"] } semver = "1.0" serde = { version = "1.0", features = ["derive", "rc"] } @@ -625,8 +632,10 @@ wasmtime = { version = "29", default-features = false, features = [ ] } wasmtime-wasi = "29" which = "6.0.0" +windows-core = "0.61" +wit-component = "0.221" workspace-hack = "0.1.0" -zed_llm_client = "= 0.8.5" +zed_llm_client = "= 0.8.6" zstd = "0.11" [workspace.dependencies.async-stripe] @@ -661,6 +670,7 @@ features = [ "Win32_Graphics_Gdi", "Win32_Graphics_Imaging", "Win32_Graphics_Imaging_D2D", + "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Credentials", "Win32_Storage_FileSystem", diff --git a/assets/icons/ai_gemini.svg b/assets/icons/ai_gemini.svg new file mode 100644 index 0000000000000000000000000000000000000000..60197dc4adcf912128756b32ead43b8b1da61222 --- /dev/null +++ b/assets/icons/ai_gemini.svg @@ -0,0 +1 @@ +Google Gemini diff --git a/assets/icons/bolt_filled_alt.svg b/assets/icons/bolt_filled_alt.svg index 3c8938736279684981b03d168b11272d4e196d24..141e1c5f577bbd9bdc661de6629f863bfc760de9 100644 --- a/assets/icons/bolt_filled_alt.svg +++ b/assets/icons/bolt_filled_alt.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/clipboard.svg b/assets/icons/clipboard.svg deleted file mode 100644 index 5c8842f3b7898cc59386679d572abb73378d4332..0000000000000000000000000000000000000000 --- a/assets/icons/clipboard.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg index 8cea0c460402fbb36769aa0aaadab9f80513d101..ff51e42b1a9483f4f6d0382d67aa34bd3405f1ff 100644 --- a/assets/icons/debug.svg +++ b/assets/icons/debug.svg @@ -1 +1,12 @@ - + + + + + + + + + + + + diff --git a/assets/icons/file_delete.svg b/assets/icons/file_delete.svg deleted file mode 100644 index b84f79958f39dd205742e945c44859a6f1000881..0000000000000000000000000000000000000000 --- a/assets/icons/file_delete.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/file_tree.svg b/assets/icons/file_tree.svg index 4c921b135183b7b58126f16c68f39aec22677285..a140cd70b12d1be180d2c683d59400212969c47a 100644 --- a/assets/icons/file_tree.svg +++ b/assets/icons/file_tree.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/git_branch_small.svg b/assets/icons/git_branch_small.svg index d23fc176ac797fff35c6c9d35176d5e03c6170fe..22832d6fedfc5221c31c81eae497f8172b59c21e 100644 --- a/assets/icons/git_branch_small.svg +++ b/assets/icons/git_branch_small.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/assets/icons/list_tree.svg b/assets/icons/list_tree.svg index 8cf157ec135d13395fc8ac66d8f8086f0d199a2e..09872a60f7ed9c85e89f06b7384b083a7f4b5779 100644 --- a/assets/icons/list_tree.svg +++ b/assets/icons/list_tree.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/assets/icons/location_edit.svg b/assets/icons/location_edit.svg new file mode 100644 index 0000000000000000000000000000000000000000..de82e8db4e05da232d024d6a92e329fd15a94ff0 --- /dev/null +++ b/assets/icons/location_edit.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_filled.svg b/assets/icons/play_filled.svg new file mode 100644 index 0000000000000000000000000000000000000000..387304ef0438cc7e5583267abc4b63624d4231df --- /dev/null +++ b/assets/icons/play_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/search_code.svg b/assets/icons/search_code.svg deleted file mode 100644 index 1cc9affeb80fe8111de1417a0241497de663ee90..0000000000000000000000000000000000000000 --- a/assets/icons/search_code.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/terminal_alt.svg b/assets/icons/terminal_alt.svg new file mode 100644 index 0000000000000000000000000000000000000000..7afb89db2130b8d9233c1662d7fcf86f63de305a --- /dev/null +++ b/assets/icons/terminal_alt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_bulb.svg b/assets/icons/tool_bulb.svg new file mode 100644 index 0000000000000000000000000000000000000000..54d5ac5fd78f548eb8d9406df624378e08a497e2 --- /dev/null +++ b/assets/icons/tool_bulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_copy.svg b/assets/icons/tool_copy.svg new file mode 100644 index 0000000000000000000000000000000000000000..e722d8a022fca603b87fc1859436fcc060355095 --- /dev/null +++ b/assets/icons/tool_copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_delete_file.svg b/assets/icons/tool_delete_file.svg new file mode 100644 index 0000000000000000000000000000000000000000..3276f3d78e8ca1bb6d79a58845577cb150f545aa --- /dev/null +++ b/assets/icons/tool_delete_file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_diagnostics.svg b/assets/icons/tool_diagnostics.svg new file mode 100644 index 0000000000000000000000000000000000000000..c659d967812727450bc3efb825b6492e6d2eda50 --- /dev/null +++ b/assets/icons/tool_diagnostics.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg new file mode 100644 index 0000000000000000000000000000000000000000..9d3ac299d2b6c9696b6aa28c2148c9d4af9f819d --- /dev/null +++ b/assets/icons/tool_folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_hammer.svg b/assets/icons/tool_hammer.svg new file mode 100644 index 0000000000000000000000000000000000000000..e66173ce70f39416bbfdbfdb97dfa6f99e1ef3b7 --- /dev/null +++ b/assets/icons/tool_hammer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_notification.svg b/assets/icons/tool_notification.svg new file mode 100644 index 0000000000000000000000000000000000000000..7510b3204000d714e8fb120179cbfc521e1abdd8 --- /dev/null +++ b/assets/icons/tool_notification.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_pencil.svg b/assets/icons/tool_pencil.svg new file mode 100644 index 0000000000000000000000000000000000000000..b913015c08ae5e7fc2ceb6011bd89925cedc27fe --- /dev/null +++ b/assets/icons/tool_pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_read.svg b/assets/icons/tool_read.svg new file mode 100644 index 0000000000000000000000000000000000000000..458cbb36607a308ae4ce5e6a98006f9ff87461e8 --- /dev/null +++ b/assets/icons/tool_read.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/tool_regex.svg b/assets/icons/tool_regex.svg new file mode 100644 index 0000000000000000000000000000000000000000..0432cd570fe2341829c40f5d4e629e3b27e24379 --- /dev/null +++ b/assets/icons/tool_regex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_search.svg b/assets/icons/tool_search.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f2750cfa2624ff4419c159fda5b62a515b43113 --- /dev/null +++ b/assets/icons/tool_search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_terminal.svg b/assets/icons/tool_terminal.svg new file mode 100644 index 0000000000000000000000000000000000000000..5154fa8e700ce8169cff3ae278708250030fc506 --- /dev/null +++ b/assets/icons/tool_terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_web.svg b/assets/icons/tool_web.svg new file mode 100644 index 0000000000000000000000000000000000000000..6250a9f05ab53d2bc364dc7520d10ee319f29f1f --- /dev/null +++ b/assets/icons/tool_web.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/user_group.svg b/assets/icons/user_group.svg index aa99277646653c899ee049547e5574b76b25b840..ac1f7bdc633190f88b202d9e5ae7430af225aecd 100644 --- a/assets/icons/user_group.svg +++ b/assets/icons/user_group.svg @@ -1,3 +1,5 @@ - + + + diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg index 693d86f929ff170f08edf3d2a0a7a28af17a30bf..d21252de8c234611ddd41caff287e3fc0d540ed3 100644 --- a/assets/icons/zed_assistant.svg +++ b/assets/icons/zed_assistant.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index f0ed829c1ec9237eec78386ab88b151ab59ad8f5..02d08347fee1c6d4c29db76f93206f7ed45b884f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -268,6 +268,14 @@ "ctrl-alt-t": "agent::NewThread" } }, + { + "context": "AgentPanel && acp_thread", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewAcpThread", + "ctrl-alt-t": "agent::NewThread" + } + }, { "context": "MessageEditor > Editor", "bindings": { @@ -306,6 +314,16 @@ "enter": "agent::AcceptSuggestedContext" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage", + "shift-ctrl-r": "agent::OpenAgentDiff" + } + }, { "context": "ThreadHistory", "bindings": { @@ -457,8 +475,8 @@ "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-u": "editor::UndoSelection", "ctrl-shift-u": "editor::RedoSelection", - "f8": "editor::GoToDiagnostic", - "shift-f8": "editor::GoToPreviousDiagnostic", + "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", @@ -605,7 +623,9 @@ // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - "f5": "debugger::Rerun" + "f5": "debugger::Rerun", + "ctrl-f4": "workspace::CloseActiveDock", + "ctrl-w": "workspace::CloseActiveDock" } }, { @@ -836,6 +856,7 @@ "alt-shift-y": "git::UnstageFile", "ctrl-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", + "shift-space": "git::StageRange", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", @@ -978,6 +999,7 @@ { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { + "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", "ctrl-shift-i": "file_finder::ToggleFilterMenu" } @@ -1093,7 +1115,10 @@ "context": "KeymapEditor", "use_key_equivalents": true, "bindings": { - "ctrl-f": "search::FocusSearch" + "ctrl-f": "search::FocusSearch", + "alt-find": "keymap_editor::ToggleKeystrokeSearch", + "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", + "alt-c": "keymap_editor::ToggleConflictFilter" } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index cd986e12e9c94de78b503f26a3bb89b9307446d5..ecb8648978bd677d526e5bdf921383ab6e4c8753 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -8,7 +8,7 @@ "shift-cmd-f5": "debugger::RerunSession", "f6": "debugger::Pause", "f7": "debugger::StepOver", - "f11": "debugger::StepInto", + "ctrl-f11": "debugger::StepInto", "shift-f11": "debugger::StepOut", "home": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", @@ -309,6 +309,14 @@ "cmd-alt-t": "agent::NewThread" } }, + { + "context": "AgentPanel && acp_thread", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "agent::NewAcpThread", + "cmd-alt-t": "agent::NewThread" + } + }, { "context": "MessageEditor > Editor", "use_key_equivalents": true, @@ -357,6 +365,16 @@ "ctrl--": "pane::GoBack" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage", + "shift-ctrl-r": "agent::OpenAgentDiff" + } + }, { "context": "ThreadHistory", "bindings": { @@ -510,8 +528,8 @@ "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], "cmd-u": "editor::UndoSelection", "cmd-shift-u": "editor::RedoSelection", - "f8": "editor::GoToDiagnostic", - "shift-f8": "editor::GoToPreviousDiagnostic", + "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", @@ -659,7 +677,8 @@ "cmd-k shift-up": "workspace::SwapPaneUp", "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", - "f5": "debugger::Rerun" + "f5": "debugger::Rerun", + "cmd-w": "workspace::CloseActiveDock" } }, { @@ -911,6 +930,7 @@ "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", + "shift-space": "git::StageRange", "cmd-y": "git::StageFile", "cmd-shift-y": "git::UnstageFile", "alt-down": "git_panel::FocusEditor", @@ -1078,6 +1098,7 @@ "ctrl-cmd-space": "terminal::ShowCharacterPalette", "cmd-c": "terminal::Copy", "cmd-v": "terminal::Paste", + "cmd-f": "buffer_search::Deploy", "cmd-a": "editor::SelectAll", "cmd-k": "terminal::Clear", "cmd-n": "workspace::NewTerminal", @@ -1193,7 +1214,8 @@ "context": "KeymapEditor", "use_key_equivalents": true, "bindings": { - "cmd-f": "search::FocusSearch" + "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", + "cmd-alt-c": "keymap_editor::ToggleConflictFilter" } } ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 639f1cefade18ee46771d22e08eda4a24f8696c0..dcb52e5250335531ebfa6e4146614ee8b9adf73a 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -189,6 +189,8 @@ "z shift-r": "editor::UnfoldAll", "z l": "vim::ColumnRight", "z h": "vim::ColumnLeft", + "z shift-l": "vim::HalfPageRight", + "z shift-h": "vim::HalfPageLeft", "shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }], "shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }], // Count support @@ -218,35 +220,18 @@ "context": "vim_mode == normal", "bindings": { "ctrl-[": "editor::Cancel", - "escape": "editor::Cancel", ":": "command_palette::Toggle", "c": "vim::PushChange", "shift-c": "vim::ChangeToEndOfLine", "d": "vim::PushDelete", "delete": "vim::DeleteRight", - "shift-d": "vim::DeleteToEndOfLine", - "shift-j": "vim::JoinLines", "g shift-j": "vim::JoinLinesNoWhitespace", "y": "vim::PushYank", - "shift-y": "vim::YankLine", - "i": "vim::InsertBefore", - "shift-i": "vim::InsertFirstNonWhitespace", - "a": "vim::InsertAfter", - "shift-a": "vim::InsertEndOfLine", "x": "vim::DeleteRight", "shift-x": "vim::DeleteLeft", - "o": "vim::InsertLineBelow", - "shift-o": "vim::InsertLineAbove", - "~": "vim::ChangeCase", "ctrl-a": "vim::Increment", "ctrl-x": "vim::Decrement", - "p": "vim::Paste", - "shift-p": ["vim::Paste", { "before": true }], - "u": "vim::Undo", "ctrl-r": "vim::Redo", - "r": "vim::PushReplace", - "s": "vim::Substitute", - "shift-s": "vim::SubstituteLine", ">": "vim::PushIndent", "<": "vim::PushOutdent", "=": "vim::PushAutoIndent", @@ -256,11 +241,8 @@ "g ~": "vim::PushOppositeCase", "g ?": "vim::PushRot13", // "g ?": "vim::PushRot47", - "\"": "vim::PushRegister", "g w": "vim::PushRewrap", "g q": "vim::PushRewrap", - "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem", "insert": "vim::InsertBefore", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", @@ -327,6 +309,7 @@ "g shift-r": ["vim::Paste", { "preserve_clipboard": true }], "g c": "vim::ToggleComments", "g q": "vim::Rewrap", + "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", "\"": "vim::PushRegister", @@ -363,18 +346,11 @@ } }, { - "context": "vim_mode == helix_normal && !menu", + "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", "bindings": { "escape": "editor::Cancel", - "ctrl-[": "editor::Cancel", - ":": "command_palette::Toggle", - "left": "vim::WrappingLeft", - "right": "vim::WrappingRight", - "h": "vim::WrappingLeft", - "l": "vim::WrappingRight", "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", - "y": "editor::Copy", "shift-y": "vim::YankLine", "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", @@ -388,27 +364,40 @@ "p": "vim::Paste", "shift-p": ["vim::Paste", { "before": true }], "u": "vim::Undo", + "shift-u": "vim::UndoLastLine", + "r": "vim::PushReplace", + "s": "vim::Substitute", + "shift-s": "vim::SubstituteLine", + "\"": "vim::PushRegister", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-pageup": "pane::ActivatePreviousItem" + } + }, + { + "context": "vim_mode == helix_normal && !menu", + "bindings": { + "ctrl-[": "editor::Cancel", + ":": "command_palette::Toggle", + "left": "vim::WrappingLeft", + "right": "vim::WrappingRight", + "h": "vim::WrappingLeft", + "l": "vim::WrappingRight", + "y": "editor::Copy", + "alt-;": "vim::OtherEnd", "ctrl-r": "vim::Redo", "f": ["vim::PushFindForward", { "before": false, "multiline": true }], "t": ["vim::PushFindForward", { "before": true, "multiline": true }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], - "r": "vim::PushReplace", - "s": "vim::Substitute", - "shift-s": "vim::SubstituteLine", ">": "vim::Indent", "<": "vim::Outdent", "=": "vim::AutoIndent", "g u": "vim::PushLowercase", "g shift-u": "vim::PushUppercase", "g ~": "vim::PushOppositeCase", - "\"": "vim::PushRegister", "g q": "vim::PushRewrap", "g w": "vim::PushRewrap", - "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem", "insert": "vim::InsertBefore", - ".": "vim::Repeat", "alt-.": "vim::RepeatFind", // tree-sitter related commands "[ x": "editor::SelectLargerSyntaxNode", @@ -428,7 +417,6 @@ "g h": "vim::StartOfLine", "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g e": "vim::EndOfDocument", - "g y": "editor::GoToTypeDefinition", "g r": "editor::FindAllReferences", // zed specific "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", @@ -478,7 +466,7 @@ } }, { - "context": "vim_mode == insert && showing_signature_help && !showing_completions", + "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", "ctrl-n": "editor::SignatureHelpNext" @@ -853,6 +841,7 @@ "i": "git_panel::FocusEditor", "x": "git::ToggleStaged", "shift-x": "git::StageAll", + "g x": "git::StageRange", "shift-u": "git::UnstageAll" } }, diff --git a/assets/settings/default.json b/assets/settings/default.json index f2effd7fbd278431945a5d6f4b8292cb98cade06..edf07fdbf98e745998f3fac553de2b0a5d78cefd 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -228,7 +228,12 @@ // Whether to show code action button at start of buffer line. "inline_code_actions": true, // Whether to allow drag and drop text selection in buffer. - "drag_and_drop_selection": true, + "drag_and_drop_selection": { + // When true, enables drag and drop text selection in buffer. + "enabled": true, + // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. + "delay": 300 + }, // What to do when go to definition yields no results. // // 1. Do nothing: `none` @@ -357,7 +362,9 @@ // Whether to show user picture in the titlebar. "show_user_picture": true, // Whether to show the sign in button in the titlebar. - "show_sign_in": true + "show_sign_in": true, + // Whether to show the menus in the titlebar. + "show_menus": false }, // Scrollbar related settings "scrollbar": { @@ -617,6 +624,8 @@ // 3. Mark files with errors and warnings: // "all" "show_diagnostics": "all", + // Whether to stick parent directories at top of the project panel. + "sticky_scroll": true, // Settings related to indent guides in the project panel. "indent_guides": { // When to show indent guides in the project panel. @@ -746,8 +755,6 @@ "default_width": 380 }, "agent": { - // Version of this setting. - "version": "2", // Whether the agent is enabled. "enabled": true, /// What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. @@ -810,6 +817,7 @@ "edit_file": true, "fetch": true, "list_directory": true, + "project_notifications": true, "move_path": true, "now": true, "find_path": true, @@ -829,6 +837,7 @@ "diagnostics": true, "fetch": true, "list_directory": true, + "project_notifications": true, "now": true, "find_path": true, "read_file": true, @@ -855,7 +864,15 @@ // its response, or needs user input. // Default: false - "play_sound_when_agent_done": 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 + "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": { @@ -1118,6 +1135,7 @@ "**/.svn", "**/.hg", "**/.jj", + "**/.repo", "**/CVS", "**/.DS_Store", "**/Thumbs.db", @@ -1140,16 +1158,14 @@ // Control whether the git blame information is shown inline, // in the currently focused line. "inline_blame": { - "enabled": true + "enabled": true, // Sets a delay after which the inline blame information is shown. // Delay is restarted with every cursor movement. - // "delay_ms": 600 - // + "delay_ms": 0, // Whether or not to display the git commit summary on the same line. - // "show_commit_summary": false - // + "show_commit_summary": false, // The minimum column number to show the inline blame information at - // "min_column": 0 + "min_column": 0 }, // How git hunks are displayed visually in the editor. // This setting can take two values: @@ -1292,6 +1308,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 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 @@ -1348,7 +1366,7 @@ // 5. Never show the scrollbar: // "never" "show": null - } + }, // Set the terminal's font size. If this option is not included, // the terminal will default to matching the buffer's font size. // "font_size": 15, @@ -1360,11 +1378,26 @@ // This will be merged with the platform's default font fallbacks // "font_fallbacks": ["FiraCode Nerd Fonts"], // The weight of the editor font in standard CSS units from 100 to 900. - // "font_weight": 400 + "font_weight": 400, // Sets the maximum number of lines in the terminal's scrollback buffer. // Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling. // Existing terminals will not pick up this change until they are recreated. - // "max_scroll_history_lines": 10000, + "max_scroll_history_lines": 10000, + // 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 + // + // Most terminal themes have APCA values of 40-70. + // A value of 45 preserves colorful themes while ensuring legibility. + "minimum_contrast": 45 }, "code_actions_on_format": {}, // Settings related to running tasks. @@ -1576,6 +1609,9 @@ "use_on_type_format": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled" + }, "prettier": { "allowed": true } @@ -1589,6 +1625,9 @@ } }, "Plain Text": { + "completions": { + "words": "disabled" + }, "allow_rewrap": "anywhere" }, "Python": { @@ -1656,7 +1695,6 @@ // Different settings for specific language models. "language_models": { "anthropic": { - "version": "1", "api_url": "https://api.anthropic.com" }, "google": { @@ -1666,7 +1704,6 @@ "api_url": "http://localhost:11434" }, "openai": { - "version": "1", "api_url": "https://api.openai.com/v1" }, "open_router": { @@ -1819,6 +1856,8 @@ "read_ssh_config": true, // Configures context servers for use by the agent. "context_servers": {}, + // Configures agent servers available in the agent panel. + "agent_servers": {}, "debugger": { "stepping_granularity": "line", "save_breakpoints": true, diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index 75e42b2d1b43b6fa87eeb28ba74529b2b265e0ac..78fc1fc5f02a03bc83c93a4cf5cc7c517fd301c7 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -3,13 +3,6 @@ // For more documentation on how to configure debug tasks, // see: https://zed.dev/docs/debugger [ - { - "label": "Debug active PHP file", - "adapter": "PHP", - "program": "$ZED_FILE", - "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" - }, { "label": "Debug active Python file", "adapter": "Debugpy", diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..1570aeaef083e692928d5bb3f6da8de2d79f5c0b --- /dev/null +++ b/crates/acp/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "acp" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/acp.rs" +doctest = false + +[features] +test-support = ["gpui/test-support", "project/test-support"] +gemini = [] + +[dependencies] +agent_servers.workspace = true +agentic-coding-protocol.workspace = true +anyhow.workspace = true +assistant_tool.workspace = true +buffer_diff.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +itertools.workspace = true +language.workspace = true +markdown.workspace = true +project.workspace = true +settings.workspace = true +smol.workspace = true +ui.workspace = true +util.workspace = true +workspace-hack.workspace = true + +[dev-dependencies] +async-pipe.workspace = true +env_logger.workspace = true +gpui = { workspace = true, "features" = ["test-support"] } +indoc.workspace = true +project = { workspace = true, "features" = ["test-support"] } +serde_json.workspace = true +tempfile.workspace = true +util.workspace = true +settings.workspace = true diff --git a/crates/acp/LICENSE-GPL b/crates/acp/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/acp/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs new file mode 100644 index 0000000000000000000000000000000000000000..8351aeaee0ef1d12a6db938aa3949d7bd19ccb43 --- /dev/null +++ b/crates/acp/src/acp.rs @@ -0,0 +1,1926 @@ +pub use acp::ToolCallId; +use agent_servers::AgentServer; +use agentic_coding_protocol::{self as acp, UserMessageChunk}; +use anyhow::{Context as _, Result, anyhow}; +use assistant_tool::ActionLog; +use buffer_diff::BufferDiff; +use editor::{MultiBuffer, PathKey}; +use futures::{FutureExt, channel::oneshot, future::BoxFuture}; +use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; +use itertools::Itertools; +use language::{ + Anchor, Buffer, BufferSnapshot, Capability, LanguageRegistry, OffsetRangeExt as _, Point, + text_diff, +}; +use markdown::Markdown; +use project::{AgentLocation, Project}; +use std::collections::HashMap; +use std::error::Error; +use std::fmt::{Formatter, Write}; +use std::{ + fmt::Display, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{App, IconName}; +use util::ResultExt; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserMessage { + pub content: Entity, +} + +impl UserMessage { + pub fn from_acp( + message: &acp::SendUserMessageParams, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let mut md_source = String::new(); + + for chunk in &message.chunks { + match chunk { + UserMessageChunk::Text { text } => md_source.push_str(&text), + UserMessageChunk::Path { path } => { + write!(&mut md_source, "{}", MentionPath(&path)).unwrap() + } + } + } + + Self { + content: cx + .new(|cx| Markdown::new(md_source.into(), Some(language_registry), None, cx)), + } + } + + fn to_markdown(&self, cx: &App) -> String { + format!("## User\n\n{}\n\n", self.content.read(cx).source()) + } +} + +#[derive(Debug)] +pub struct MentionPath<'a>(&'a Path); + +impl<'a> MentionPath<'a> { + const PREFIX: &'static str = "@file:"; + + pub fn new(path: &'a Path) -> Self { + MentionPath(path) + } + + pub fn try_parse(url: &'a str) -> Option { + let path = url.strip_prefix(Self::PREFIX)?; + Some(MentionPath(Path::new(path))) + } + + pub fn path(&self) -> &Path { + self.0 + } +} + +impl Display for MentionPath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[@{}]({}{})", + self.0.file_name().unwrap_or_default().display(), + Self::PREFIX, + self.0.display() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssistantMessage { + pub chunks: Vec, +} + +impl AssistantMessage { + fn to_markdown(&self, cx: &App) -> String { + format!( + "## Assistant\n\n{}\n\n", + self.chunks + .iter() + .map(|chunk| chunk.to_markdown(cx)) + .join("\n\n") + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssistantMessageChunk { + Text { chunk: Entity }, + Thought { chunk: Entity }, +} + +impl AssistantMessageChunk { + pub fn from_acp( + chunk: acp::AssistantMessageChunk, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match chunk { + acp::AssistantMessageChunk::Text { text } => Self::Text { + chunk: cx.new(|cx| Markdown::new(text.into(), Some(language_registry), None, cx)), + }, + acp::AssistantMessageChunk::Thought { thought } => Self::Thought { + chunk: cx + .new(|cx| Markdown::new(thought.into(), Some(language_registry), None, cx)), + }, + } + } + + pub fn from_str(chunk: &str, language_registry: Arc, cx: &mut App) -> Self { + Self::Text { + chunk: cx.new(|cx| { + Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx) + }), + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Text { chunk } => chunk.read(cx).source().to_string(), + Self::Thought { chunk } => { + format!("\n{}\n", chunk.read(cx).source()) + } + } + } +} + +#[derive(Debug)] +pub enum AgentThreadEntry { + UserMessage(UserMessage), + AssistantMessage(AssistantMessage), + ToolCall(ToolCall), +} + +impl AgentThreadEntry { + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::UserMessage(message) => message.to_markdown(cx), + Self::AssistantMessage(message) => message.to_markdown(cx), + Self::ToolCall(too_call) => too_call.to_markdown(cx), + } + } + + pub fn diff(&self) -> Option<&Diff> { + if let AgentThreadEntry::ToolCall(ToolCall { + content: Some(ToolCallContent::Diff { diff }), + .. + }) = self + { + Some(&diff) + } else { + None + } + } + + pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> { + if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self { + Some(locations) + } else { + None + } + } +} + +#[derive(Debug)] +pub struct ToolCall { + pub id: acp::ToolCallId, + pub label: Entity, + pub icon: IconName, + pub content: Option, + pub status: ToolCallStatus, + pub locations: Vec, +} + +impl ToolCall { + fn to_markdown(&self, cx: &App) -> String { + let mut markdown = format!( + "**Tool Call: {}**\nStatus: {}\n\n", + self.label.read(cx).source(), + self.status + ); + if let Some(content) = &self.content { + markdown.push_str(content.to_markdown(cx).as_str()); + markdown.push_str("\n\n"); + } + markdown + } +} + +#[derive(Debug)] +pub enum ToolCallStatus { + WaitingForConfirmation { + confirmation: ToolCallConfirmation, + respond_tx: oneshot::Sender, + }, + Allowed { + status: acp::ToolCallStatus, + }, + Rejected, + Canceled, +} + +impl Display for ToolCallStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation", + ToolCallStatus::Allowed { status } => match status { + acp::ToolCallStatus::Running => "Running", + acp::ToolCallStatus::Finished => "Finished", + acp::ToolCallStatus::Error => "Error", + }, + ToolCallStatus::Rejected => "Rejected", + ToolCallStatus::Canceled => "Canceled", + } + ) + } +} + +#[derive(Debug)] +pub enum ToolCallConfirmation { + Edit { + description: Option>, + }, + Execute { + command: String, + root_command: String, + description: Option>, + }, + Mcp { + server_name: String, + tool_name: String, + tool_display_name: String, + description: Option>, + }, + Fetch { + urls: Vec, + description: Option>, + }, + Other { + description: Entity, + }, +} + +impl ToolCallConfirmation { + pub fn from_acp( + confirmation: acp::ToolCallConfirmation, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let to_md = |description: String, cx: &mut App| -> Entity { + cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }) + }; + + match confirmation { + acp::ToolCallConfirmation::Edit { description } => Self::Edit { + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Execute { + command, + root_command, + description, + } => Self::Execute { + command, + root_command, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Mcp { + server_name, + tool_name, + tool_display_name, + description, + } => Self::Mcp { + server_name, + tool_name, + tool_display_name, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch { + urls: urls.iter().map(|url| url.into()).collect(), + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Other { description } => Self::Other { + description: to_md(description, cx), + }, + } + } +} + +#[derive(Debug)] +pub enum ToolCallContent { + Markdown { markdown: Entity }, + Diff { diff: Diff }, +} + +impl ToolCallContent { + pub fn from_acp( + content: acp::ToolCallContent, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match content { + acp::ToolCallContent::Markdown { markdown } => Self::Markdown { + markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)), + }, + acp::ToolCallContent::Diff { diff } => Self::Diff { + diff: Diff::from_acp(diff, language_registry, cx), + }, + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Markdown { markdown } => markdown.read(cx).source().to_string(), + Self::Diff { diff } => diff.to_markdown(cx), + } + } +} + +#[derive(Debug)] +pub struct Diff { + pub multibuffer: Entity, + pub path: PathBuf, + pub new_buffer: Entity, + pub old_buffer: Entity, + _task: Task>, +} + +impl Diff { + pub fn from_acp( + diff: acp::Diff, + language_registry: Arc, + cx: &mut App, + ) -> 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 old_buffer_snapshot = old_buffer.read(cx).snapshot(); + let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); + let diff_task = buffer_diff.update(cx, |diff, cx| { + diff.set_base_text( + old_buffer_snapshot, + Some(language_registry.clone()), + new_buffer_snapshot, + cx, + ) + }); + + let task = cx.spawn({ + let multibuffer = multibuffer.clone(); + let path = path.clone(); + let new_buffer = new_buffer.clone(); + async move |cx| { + diff_task.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)) + .collect::>() + }; + + multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&new_buffer, cx), + new_buffer.clone(), + hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + }) + .log_err(); + + if let Some(language) = language_registry + .language_for_file_path(&path) + .await + .log_err() + { + new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?; + } + + anyhow::Ok(()) + } + }); + + Self { + multibuffer, + path, + new_buffer, + old_buffer, + _task: task, + } + } + + fn to_markdown(&self, cx: &App) -> String { + let buffer_text = self + .multibuffer + .read(cx) + .all_buffers() + .iter() + .map(|buffer| buffer.read(cx).text()) + .join("\n"); + format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text) + } +} + +pub struct AcpThread { + entries: Vec, + title: SharedString, + project: Entity, + action_log: Entity, + shared_buffers: HashMap, BufferSnapshot>, + send_task: Option>, + connection: Arc, + child_status: Option>>, + _io_task: Task<()>, +} + +pub enum AcpThreadEvent { + NewEntry, + EntryUpdated(usize), +} + +impl EventEmitter for AcpThread {} + +#[derive(PartialEq, Eq)] +pub enum ThreadStatus { + Idle, + WaitingForToolConfirmation, + Generating, +} + +#[derive(Debug, Clone)] +pub enum LoadError { + Unsupported { current_version: SharedString }, + Exited(i32), + Other(SharedString), +} + +impl Display for LoadError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LoadError::Unsupported { current_version } => { + write!( + f, + "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).", + current_version + ) + } + LoadError::Exited(status) => write!(f, "Server exited with status {}", status), + LoadError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for LoadError {} + +impl AcpThread { + pub async fn spawn( + server: impl AgentServer + 'static, + root_dir: &Path, + project: Entity, + cx: &mut AsyncApp, + ) -> Result> { + let command = match server.command(&project, cx).await { + Ok(command) => command, + Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))), + }; + + 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(); + + cx.new(|cx| { + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()), + 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 { + match child.status().await { + Err(e) => Err(anyhow!(e)), + Ok(result) if result.success() => Ok(()), + Ok(result) => { + if let Some(version) = server.version(&command).await.log_err() + && !version.supported + { + Err(anyhow!(LoadError::Unsupported { + current_version: version.current_version + })) + } else { + Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127)))) + } + } + } + }); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + Self { + action_log, + shared_buffers: Default::default(), + entries: Default::default(), + title: "ACP Thread".into(), + project, + send_task: None, + connection: Arc::new(connection), + child_status: Some(child_status), + _io_task: io_task, + } + }) + } + + pub fn action_log(&self) -> &Entity { + &self.action_log + } + + pub fn project(&self) -> &Entity { + &self.project + } + + #[cfg(test)] + pub fn fake( + stdin: async_pipe::PipeWriter, + stdout: async_pipe::PipeReader, + project: Entity, + cx: &mut Context, + ) -> Self { + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()), + stdin, + stdout, + move |fut| { + foreground_executor.spawn(fut).detach(); + }, + ); + + let io_task = cx.background_spawn({ + async move { + io_fut.await.log_err(); + } + }); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + Self { + action_log, + shared_buffers: Default::default(), + entries: Default::default(), + title: "ACP Thread".into(), + project, + send_task: None, + connection: Arc::new(connection), + child_status: None, + _io_task: io_task, + } + } + + pub fn title(&self) -> SharedString { + self.title.clone() + } + + pub fn entries(&self) -> &[AgentThreadEntry] { + &self.entries + } + + pub fn status(&self) -> ThreadStatus { + if self.send_task.is_some() { + if self.waiting_for_tool_confirmation() { + ThreadStatus::WaitingForToolConfirmation + } else { + ThreadStatus::Generating + } + } else { + ThreadStatus::Idle + } + } + + pub fn has_pending_edit_tool_calls(&self) -> bool { + for entry in self.entries.iter().rev() { + match entry { + AgentThreadEntry::UserMessage(_) => return false, + AgentThreadEntry::ToolCall(ToolCall { + status: + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + }, + content: Some(ToolCallContent::Diff { .. }), + .. + }) => return true, + AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + } + } + + false + } + + pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + self.entries.push(entry); + cx.emit(AcpThreadEvent::NewEntry); + } + + pub fn push_assistant_chunk( + &mut self, + chunk: acp::AssistantMessageChunk, + cx: &mut Context, + ) { + let entries_len = self.entries.len(); + if let Some(last_entry) = self.entries.last_mut() + && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry + { + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + + match (chunks.last_mut(), &chunk) { + ( + Some(AssistantMessageChunk::Text { chunk: old_chunk }), + acp::AssistantMessageChunk::Text { text: new_chunk }, + ) + | ( + Some(AssistantMessageChunk::Thought { chunk: old_chunk }), + acp::AssistantMessageChunk::Thought { thought: new_chunk }, + ) => { + old_chunk.update(cx, |old_chunk, cx| { + old_chunk.append(&new_chunk, cx); + }); + } + _ => { + chunks.push(AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + )); + } + } + } else { + let chunk = AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + ); + + self.push_entry( + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks: vec![chunk], + }), + cx, + ); + } + } + + pub fn request_tool_call( + &mut self, + tool_call: acp::RequestToolCallConfirmationParams, + cx: &mut Context, + ) -> ToolCallRequest { + let (tx, rx) = oneshot::channel(); + + let status = ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::from_acp( + tool_call.confirmation, + self.project.read(cx).languages().clone(), + cx, + ), + respond_tx: tx, + }; + + let id = self.insert_tool_call(tool_call.tool_call, status, cx); + ToolCallRequest { id, outcome: rx } + } + + pub fn push_tool_call( + &mut self, + request: acp::PushToolCallParams, + cx: &mut Context, + ) -> acp::ToolCallId { + let status = ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + }; + + self.insert_tool_call(request, status, cx) + } + + fn insert_tool_call( + &mut self, + tool_call: acp::PushToolCallParams, + status: ToolCallStatus, + cx: &mut Context, + ) -> acp::ToolCallId { + let language_registry = self.project.read(cx).languages().clone(); + let id = acp::ToolCallId(self.entries.len() as u64); + let call = ToolCall { + id, + label: cx.new(|cx| { + Markdown::new( + tool_call.label.into(), + Some(language_registry.clone()), + None, + cx, + ) + }), + icon: acp_icon_to_ui_icon(tool_call.icon), + content: tool_call + .content + .map(|content| ToolCallContent::from_acp(content, language_registry, cx)), + locations: tool_call.locations, + status, + }; + + self.push_entry(AgentThreadEntry::ToolCall(call), cx); + + id + } + + pub fn authorize_tool_call( + &mut self, + id: acp::ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { + let Some((ix, call)) = self.tool_call_mut(id) else { + return; + }; + + let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject { + ToolCallStatus::Rejected + } else { + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + } + }; + + let curr_status = mem::replace(&mut call.status, new_status); + + if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { + respond_tx.send(outcome).log_err(); + } else if cfg!(debug_assertions) { + panic!("tried to authorize an already authorized tool call"); + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn update_tool_call( + &mut self, + id: acp::ToolCallId, + new_status: acp::ToolCallStatus, + new_content: Option, + cx: &mut Context, + ) -> Result<()> { + let language_registry = self.project.read(cx).languages().clone(); + let (ix, call) = self.tool_call_mut(id).context("Entry not found")?; + + call.content = new_content + .map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx)); + + match &mut call.status { + ToolCallStatus::Allowed { status } => { + *status = new_status; + } + ToolCallStatus::WaitingForConfirmation { .. } => { + anyhow::bail!("Tool call hasn't been authorized yet") + } + ToolCallStatus::Rejected => { + anyhow::bail!("Tool call was rejected and therefore can't be updated") + } + ToolCallStatus::Canceled => { + call.status = ToolCallStatus::Allowed { status: new_status }; + } + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + Ok(()) + } + + fn tool_call_mut(&mut self, id: acp::ToolCallId) -> Option<(usize, &mut ToolCall)> { + let entry = self.entries.get_mut(id.0 as usize); + debug_assert!( + entry.is_some(), + "We shouldn't give out ids to entries that don't exist" + ); + match entry { + Some(AgentThreadEntry::ToolCall(call)) if call.id == id => Some((id.0 as usize, call)), + _ => { + if cfg!(debug_assertions) { + panic!("entry is not a tool call"); + } + None + } + } + } + + /// Returns true if the last turn is awaiting tool authorization + pub fn waiting_for_tool_confirmation(&self) -> bool { + for entry in self.entries.iter().rev() { + match &entry { + AgentThreadEntry::ToolCall(call) => match call.status { + ToolCallStatus::WaitingForConfirmation { .. } => return true, + ToolCallStatus::Allowed { .. } + | ToolCallStatus::Rejected + | ToolCallStatus::Canceled => continue, + }, + AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { + // Reached the beginning of the turn + return false; + } + } + } + false + } + + pub fn initialize( + &self, + ) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { connection.initialize().await } + } + + pub fn authenticate(&self) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { connection.request(acp::AuthenticateParams).await } + } + + #[cfg(test)] + pub fn send_raw( + &mut self, + message: &str, + cx: &mut Context, + ) -> BoxFuture<'static, Result<(), acp::Error>> { + self.send( + acp::SendUserMessageParams { + chunks: vec![acp::UserMessageChunk::Text { + text: message.to_string(), + }], + }, + cx, + ) + } + + pub fn send( + &mut self, + message: acp::SendUserMessageParams, + cx: &mut Context, + ) -> BoxFuture<'static, Result<(), acp::Error>> { + let agent = self.connection.clone(); + self.push_entry( + AgentThreadEntry::UserMessage(UserMessage::from_acp( + &message, + self.project.read(cx).languages().clone(), + cx, + )), + cx, + ); + + let (tx, rx) = oneshot::channel(); + let cancel = self.cancel(cx); + + self.send_task = Some(cx.spawn(async move |this, cx| { + cancel.await.log_err(); + + let result = agent.request(message).await; + tx.send(result).log_err(); + this.update(cx, |this, _cx| this.send_task.take()).log_err(); + })); + + async move { + match rx.await { + Ok(Err(e)) => Err(e)?, + _ => Ok(()), + } + } + .boxed() + } + + pub fn cancel(&mut self, cx: &mut Context) -> Task> { + let agent = self.connection.clone(); + + if self.send_task.take().is_some() { + cx.spawn(async move |this, cx| { + agent.request(acp::CancelSendMessageParams).await?; + + this.update(cx, |this, _cx| { + for entry in this.entries.iter_mut() { + if let AgentThreadEntry::ToolCall(call) = entry { + let cancel = matches!( + call.status, + ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running + } + ); + + if cancel { + let curr_status = + mem::replace(&mut call.status, ToolCallStatus::Canceled); + + if let ToolCallStatus::WaitingForConfirmation { + respond_tx, .. + } = curr_status + { + respond_tx + .send(acp::ToolCallConfirmationOutcome::Cancel) + .ok(); + } + } + } + } + })?; + Ok(()) + }) + } else { + Task::ready(Ok(())) + } + } + + pub fn read_text_file( + &self, + request: acp::ReadTextFileParams, + cx: &mut Context, + ) -> Task> { + 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(&request.path, cx) + .context("invalid path")?; + anyhow::Ok(project.open_buffer(path, cx)) + }); + let buffer = load??.await?; + + 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(request.line.unwrap_or_default(), 0)); + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position, + }), + cx, + ); + })?; + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?; + this.update(cx, |this, _| { + let text = snapshot.text(); + this.shared_buffers.insert(buffer.clone(), snapshot); + text + }) + }) + } + + pub fn write_text_file( + &self, + path: PathBuf, + content: String, + cx: &mut Context, + ) -> Task> { + 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 snapshot = this.update(cx, |this, cx| { + this.shared_buffers + .get(&buffer) + .cloned() + .unwrap_or_else(|| buffer.read(cx).snapshot()) + })?; + let edits = cx + .background_executor() + .spawn(async move { + let old_text = snapshot.text(); + text_diff(old_text.as_str(), &content) + .into_iter() + .map(|(range, replacement)| { + ( + snapshot.anchor_after(range.start) + ..snapshot.anchor_before(range.end), + replacement, + ) + }) + .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, + ); + }); + + action_log.update(cx, |action_log, cx| { + action_log.buffer_read(buffer.clone(), cx); + }); + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + 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 child_status(&mut self) -> Option>> { + self.child_status.take() + } + + pub fn to_markdown(&self, cx: &App) -> String { + self.entries.iter().map(|e| e.to_markdown(cx)).collect() + } +} + +struct AcpClientDelegate { + thread: WeakEntity, + cx: AsyncApp, + // sent_buffer_versions: HashMap, HashMap>, +} + +impl AcpClientDelegate { + fn new(thread: WeakEntity, cx: AsyncApp) -> Self { + Self { thread, cx } + } +} + +impl acp::Client for AcpClientDelegate { + async fn stream_assistant_message_chunk( + &self, + params: acp::StreamAssistantMessageChunkParams, + ) -> Result<(), acp::Error> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread + .update(cx, |thread, cx| { + thread.push_assistant_chunk(params.chunk, cx) + }) + .ok(); + })?; + + Ok(()) + } + + async fn request_tool_call_confirmation( + &self, + request: acp::RequestToolCallConfirmationParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let ToolCallRequest { id, outcome } = cx + .update(|cx| { + self.thread + .update(cx, |thread, cx| thread.request_tool_call(request, cx)) + })? + .context("Failed to update thread")?; + + Ok(acp::RequestToolCallConfirmationResponse { + id, + outcome: outcome.await.map_err(acp::Error::into_internal_error)?, + }) + } + + async fn push_tool_call( + &self, + request: acp::PushToolCallParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let id = cx + .update(|cx| { + self.thread + .update(cx, |thread, cx| thread.push_tool_call(request, cx)) + })? + .context("Failed to update thread")?; + + Ok(acp::PushToolCallResponse { id }) + } + + async fn update_tool_call(&self, request: acp::UpdateToolCallParams) -> Result<(), acp::Error> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.update_tool_call(request.tool_call_id, request.status, request.content, cx) + }) + })? + .context("Failed to update thread")??; + + Ok(()) + } + + async fn read_text_file( + &self, + request: acp::ReadTextFileParams, + ) -> Result { + let content = self + .cx + .update(|cx| { + self.thread + .update(cx, |thread, cx| thread.read_text_file(request, cx)) + })? + .context("Failed to update thread")? + .await?; + Ok(acp::ReadTextFileResponse { content }) + } + + async fn write_text_file(&self, request: acp::WriteTextFileParams) -> Result<(), acp::Error> { + self.cx + .update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.write_text_file(request.path, request.content, cx) + }) + })? + .context("Failed to update thread")? + .await?; + + Ok(()) + } +} + +fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName { + match icon { + acp::Icon::FileSearch => IconName::ToolSearch, + acp::Icon::Folder => IconName::ToolFolder, + acp::Icon::Globe => IconName::ToolWeb, + acp::Icon::Hammer => IconName::ToolHammer, + acp::Icon::LightBulb => IconName::ToolBulb, + acp::Icon::Pencil => IconName::ToolPencil, + acp::Icon::Regex => IconName::ToolRegex, + acp::Icon::Terminal => IconName::ToolTerminal, + } +} + +pub struct ToolCallRequest { + pub id: acp::ToolCallId, + pub outcome: oneshot::Receiver, +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_servers::{AgentServerCommand, AgentServerVersion}; + use async_pipe::{PipeReader, PipeWriter}; + use futures::{channel::mpsc, future::LocalBoxFuture, select}; + use gpui::{AsyncApp, TestAppContext}; + use indoc::indoc; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use smol::{future::BoxedLocal, stream::StreamExt as _}; + use std::{cell::RefCell, env, path::Path, rc::Rc, time::Duration}; + use util::path; + + 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); + language::init(cx); + }); + } + + #[gpui::test] + async fn test_thinking_concatenation(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, fake_server) = fake_acp_thread(project, cx); + + fake_server.update(cx, |fake_server, _| { + fake_server.on_user_message(move |_, server, mut cx| async move { + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + thought: "Thinking ".into(), + }, + }) + })? + .await + .unwrap(); + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + thought: "hard!".into(), + }, + }) + })? + .await + .unwrap(); + + Ok(()) + }) + }); + + thread + .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) + .await + .unwrap(); + + let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); + assert_eq!( + output, + indoc! {r#" + ## User + + Hello from Zed! + + ## Assistant + + + Thinking hard! + + + "#} + ); + } + + #[gpui::test] + async fn test_edits_concurrently_to_user(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\n"})) + .await; + let project = Project::test(fs.clone(), [], cx).await; + let (thread, fake_server) = fake_acp_thread(project.clone(), cx); + let (worktree, pathbuf) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/tmp/foo"), true, cx) + }) + .await + .unwrap(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), pathbuf), cx) + }) + .await + .unwrap(); + + let (read_file_tx, read_file_rx) = oneshot::channel::<()>(); + let read_file_tx = Rc::new(RefCell::new(Some(read_file_tx))); + + fake_server.update(cx, |fake_server, _| { + fake_server.on_user_message(move |_, server, mut cx| { + let read_file_tx = read_file_tx.clone(); + async move { + let content = server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::ReadTextFileParams { + path: path!("/tmp/foo").into(), + line: None, + limit: None, + }) + })? + .await + .unwrap(); + assert_eq!(content.content, "one\ntwo\nthree\n"); + read_file_tx.take().unwrap().send(()).unwrap(); + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::WriteTextFileParams { + path: path!("/tmp/foo").into(), + content: "one\ntwo\nthree\nfour\nfive\n".to_string(), + }) + })? + .await + .unwrap(); + Ok(()) + } + }) + }); + + let request = thread.update(cx, |thread, cx| { + thread.send_raw("Extend the count in /tmp/foo", cx) + }); + read_file_rx.await.ok(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "zero\n".to_string())], None, cx); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "zero\none\ntwo\nthree\nfour\nfive\n" + ); + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/tmp/foo")).unwrap()).unwrap(), + "zero\none\ntwo\nthree\nfour\nfive\n" + ); + request.await.unwrap(); + } + + #[gpui::test] + async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, fake_server) = fake_acp_thread(project, cx); + + let (end_turn_tx, end_turn_rx) = oneshot::channel::<()>(); + + let tool_call_id = Rc::new(RefCell::new(None)); + let end_turn_rx = Rc::new(RefCell::new(Some(end_turn_rx))); + fake_server.update(cx, |fake_server, _| { + let tool_call_id = tool_call_id.clone(); + fake_server.on_user_message(move |_, server, mut cx| { + let end_turn_rx = end_turn_rx.clone(); + let tool_call_id = tool_call_id.clone(); + async move { + let tool_call_result = server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::PushToolCallParams { + label: "Fetch".to_string(), + icon: acp::Icon::Globe, + content: None, + locations: vec![], + }) + })? + .await + .unwrap(); + *tool_call_id.clone().borrow_mut() = Some(tool_call_result.id); + end_turn_rx.take().unwrap().await.ok(); + + Ok(()) + } + }) + }); + + let request = thread.update(cx, |thread, cx| { + thread.send_raw("Fetch https://example.com", cx) + }); + + run_until_first_tool_call(&thread, cx).await; + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + }, + .. + }) + )); + }); + + cx.run_until_parked(); + + thread + .update(cx, |thread, cx| thread.cancel(cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + &thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Canceled, + .. + }) + )); + }); + + fake_server + .update(cx, |fake_server, _| { + fake_server.send_to_zed(acp::UpdateToolCallParams { + tool_call_id: tool_call_id.borrow().unwrap(), + status: acp::ToolCallStatus::Finished, + content: None, + }) + }) + .await + .unwrap(); + + drop(end_turn_tx); + request.await.unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + }, + .. + }) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_basic(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + thread + .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.entries.len(), 2); + assert!(matches!( + thread.entries[0], + AgentThreadEntry::UserMessage(_) + )); + assert!(matches!( + thread.entries[1], + AgentThreadEntry::AssistantMessage(_) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_path_mentions(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + let tempdir = tempfile::tempdir().unwrap(); + std::fs::write( + tempdir.path().join("foo.rs"), + indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + ) + .expect("failed to write file"); + let project = Project::example([tempdir.path()], &mut cx.to_async()).await; + let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await; + thread + .update(cx, |thread, cx| { + thread.send( + acp::SendUserMessageParams { + chunks: vec![ + acp::UserMessageChunk::Text { + text: "Read the file ".into(), + }, + acp::UserMessageChunk::Path { + path: Path::new("foo.rs").into(), + }, + acp::UserMessageChunk::Text { + text: " and tell me what the content of the println! is".into(), + }, + ], + }, + cx, + ) + }) + .await + .unwrap(); + + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 3); + assert!(matches!( + thread.entries[0], + AgentThreadEntry::UserMessage(_) + )); + assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_))); + let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else { + panic!("Expected AssistantMessage") + }; + assert!( + assistant_message.to_markdown(cx).contains("Hello, world!"), + "unexpected assistant message: {:?}", + assistant_message.to_markdown(cx) + ); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_tool_call(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/private/tmp"), + json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), + ) + .await; + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + thread + .update(cx, |thread, cx| { + thread.send_raw( + "Read the '/private/tmp/foo' file and tell me what you see.", + cx, + ) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, _cx| { + assert!(matches!( + &thread.entries()[2], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + + assert!(matches!( + thread.entries[3], + AgentThreadEntry::AssistantMessage(_) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + let full_turn = thread.update(cx, |thread, cx| { + thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx) + }); + + run_until_first_tool_call(&thread, cx).await; + + let tool_call_id = thread.read_with(cx, |thread, _cx| { + let AgentThreadEntry::ToolCall(ToolCall { + id, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + .. + }) = &thread.entries()[2] + else { + panic!(); + }; + + assert_eq!(root_command, "echo"); + + *id + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx); + + assert!(matches!( + &thread.entries()[2], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + }); + + full_turn.await.unwrap(); + + thread.read_with(cx, |thread, cx| { + let AgentThreadEntry::ToolCall(ToolCall { + content: Some(ToolCallContent::Markdown { markdown }), + status: ToolCallStatus::Allowed { .. }, + .. + }) = &thread.entries()[2] + else { + panic!(); + }; + + markdown.read_with(cx, |md, _cx| { + assert!( + md.source().contains("Hello, world!"), + r#"Expected '{}' to contain "Hello, world!""#, + md.source() + ); + }); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_cancel(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + let full_turn = thread.update(cx, |thread, cx| { + thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx) + }); + + let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await; + + thread.read_with(cx, |thread, _cx| { + let AgentThreadEntry::ToolCall(ToolCall { + id, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + .. + }) = &thread.entries()[first_tool_call_ix] + else { + panic!("{:?}", thread.entries()[1]); + }; + + assert_eq!(root_command, "echo"); + + *id + }); + + thread + .update(cx, |thread, cx| thread.cancel(cx)) + .await + .unwrap(); + full_turn.await.unwrap(); + thread.read_with(cx, |thread, _| { + let AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Canceled, + .. + }) = &thread.entries()[first_tool_call_ix] + else { + panic!(); + }; + }); + + thread + .update(cx, |thread, cx| { + thread.send_raw(r#"Stop running and say goodbye to me."#, cx) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, _| { + assert!(matches!( + &thread.entries().last().unwrap(), + AgentThreadEntry::AssistantMessage(..), + )) + }); + } + + async fn run_until_first_tool_call( + thread: &Entity, + cx: &mut TestAppContext, + ) -> usize { + let (mut tx, mut rx) = mpsc::channel::(1); + + let subscription = cx.update(|cx| { + cx.subscribe(thread, move |thread, _, cx| { + for (ix, entry) in thread.read(cx).entries.iter().enumerate() { + if matches!(entry, AgentThreadEntry::ToolCall(_)) { + return tx.try_send(ix).unwrap(); + } + } + }) + }); + + select! { + _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => { + panic!("Timeout waiting for tool call") + } + ix = rx.next().fuse() => { + drop(subscription); + ix.unwrap() + } + } + } + + pub async fn gemini_acp_thread( + project: Entity, + current_dir: impl AsRef, + cx: &mut TestAppContext, + ) -> Entity { + struct DevGemini; + + impl agent_servers::AgentServer for DevGemini { + async fn command( + &self, + _project: &Entity, + _cx: &mut AsyncApp, + ) -> Result { + let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../gemini-cli/packages/cli") + .to_string_lossy() + .to_string(); + + Ok(AgentServerCommand { + path: "node".into(), + args: vec![cli_path, "--acp".into()], + env: None, + }) + } + + async fn version( + &self, + _command: &agent_servers::AgentServerCommand, + ) -> Result { + Ok(AgentServerVersion { + current_version: "0.1.0".into(), + supported: true, + }) + } + } + + let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async()) + .await + .unwrap(); + + thread + .update(cx, |thread, _| thread.initialize()) + .await + .unwrap(); + thread + } + + pub fn fake_acp_thread( + project: Entity, + cx: &mut TestAppContext, + ) -> (Entity, Entity) { + let (stdin_tx, stdin_rx) = async_pipe::pipe(); + let (stdout_tx, stdout_rx) = async_pipe::pipe(); + let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx))); + let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx))); + (thread, agent) + } + + pub struct FakeAcpServer { + connection: acp::ClientConnection, + _io_task: Task<()>, + on_user_message: Option< + Rc< + dyn Fn( + acp::SendUserMessageParams, + Entity, + AsyncApp, + ) -> LocalBoxFuture<'static, Result<(), acp::Error>>, + >, + >, + } + + #[derive(Clone)] + struct FakeAgent { + server: Entity, + cx: AsyncApp, + } + + impl acp::Agent for FakeAgent { + async fn initialize( + &self, + params: acp::InitializeParams, + ) -> Result { + Ok(acp::InitializeResponse { + protocol_version: params.protocol_version, + is_authenticated: true, + }) + } + + async fn authenticate(&self) -> Result<(), acp::Error> { + Ok(()) + } + + async fn cancel_send_message(&self) -> Result<(), acp::Error> { + Ok(()) + } + + async fn send_user_message( + &self, + request: acp::SendUserMessageParams, + ) -> Result<(), acp::Error> { + let mut cx = self.cx.clone(); + let handler = self + .server + .update(&mut cx, |server, _| server.on_user_message.clone()) + .ok() + .flatten(); + if let Some(handler) = handler { + handler(request, self.server.clone(), self.cx.clone()).await + } else { + Err(anyhow::anyhow!("No handler for on_user_message").into()) + } + } + } + + impl FakeAcpServer { + fn new(stdin: PipeReader, stdout: PipeWriter, cx: &Context) -> Self { + let agent = FakeAgent { + server: cx.entity(), + cx: cx.to_async(), + }; + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::ClientConnection::connect_to_client( + agent.clone(), + stdout, + stdin, + move |fut| { + foreground_executor.spawn(fut).detach(); + }, + ); + FakeAcpServer { + connection: connection, + on_user_message: None, + _io_task: cx.background_spawn(async move { + io_fut.await.log_err(); + }), + } + } + + fn on_user_message( + &mut self, + handler: impl for<'a> Fn(acp::SendUserMessageParams, Entity, AsyncApp) -> F + + 'static, + ) where + F: Future> + 'static, + { + self.on_user_message + .replace(Rc::new(move |request, server, cx| { + handler(request, server, cx).boxed_local() + })); + } + + fn send_to_zed( + &self, + message: T, + ) -> BoxedLocal> { + self.connection + .request(message) + .map(|f| f.map_err(|err| anyhow!(err))) + .boxed_local() + } + } +} diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index b3287e8222ccdd1f4f4ca92ff4fd4559b9fcc3f6..aee25fc9e39d533409b980782fa8f0cac3977935 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -31,7 +31,13 @@ use workspace::{StatusItemView, Workspace, item::ItemHandle}; const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0); -actions!(activity_indicator, [ShowErrorMessage]); +actions!( + activity_indicator, + [ + /// Displays error messages from language servers in the status bar. + ShowErrorMessage + ] +); pub enum Event { ShowStatus { @@ -442,7 +448,7 @@ impl ActivityIndicator { .into_any_element(), ), message: format!("Debug: {}", session.read(cx).adapter()), - tooltip_message: Some(session.read(cx).label().to_string()), + tooltip_message: session.read(cx).label().map(|label| label.to_string()), on_click: None, }); } diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs index 2c3b457dc2eef593085bb63ccb42fd70082163b3..a89857e71a6b8ed0f4e7a397be2bcd1bce4b1d7a 100644 --- a/crates/agent/src/agent_profile.rs +++ b/crates/agent/src/agent_profile.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use agent_settings::{AgentProfileId, AgentProfileSettings, AgentSettings}; -use assistant_tool::{Tool, ToolSource, ToolWorkingSet}; +use assistant_tool::{Tool, ToolSource, ToolWorkingSet, UniqueToolName}; use collections::IndexMap; use convert_case::{Case, Casing}; use fs::Fs; @@ -72,7 +72,7 @@ impl AgentProfile { &self.id } - pub fn enabled_tools(&self, cx: &App) -> Vec> { + 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(); }; @@ -81,7 +81,7 @@ impl AgentProfile { .read(cx) .tools(cx) .into_iter() - .filter(|tool| Self::is_enabled(settings, tool.source(), tool.name())) + .filter(|(_, tool)| Self::is_enabled(settings, tool.source(), tool.name())) .collect() } @@ -137,7 +137,7 @@ mod tests { let mut enabled_tools = cx .read(|cx| profile.enabled_tools(cx)) .into_iter() - .map(|tool| tool.name()) + .map(|(_, tool)| tool.name()) .collect::>(); enabled_tools.sort(); @@ -174,7 +174,7 @@ mod tests { let mut enabled_tools = cx .read(|cx| profile.enabled_tools(cx)) .into_iter() - .map(|tool| tool.name()) + .map(|(_, tool)| tool.name()) .collect::>(); enabled_tools.sort(); @@ -207,7 +207,7 @@ mod tests { let mut enabled_tools = cx .read(|cx| profile.enabled_tools(cx)) .into_iter() - .map(|tool| tool.name()) + .map(|(_, tool)| tool.name()) .collect::>(); enabled_tools.sort(); @@ -267,10 +267,10 @@ mod tests { } fn default_tool_set(cx: &mut TestAppContext) -> Entity { - cx.new(|_| { + cx.new(|cx| { let mut tool_set = ToolWorkingSet::default(); - tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp"))); - tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp"))); + 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 }) } diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/agent/src/prompts/stale_files_prompt_header.txt @@ -0,0 +1,3 @@ +[The following is an auto-generated notification; do not reply] + +These files have changed since the last read: diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 028dabbd912ab1e58273bea6302d77baf4e635b8..6a20ad8f83dd984c74a001fb86ccd564b110ce24 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -13,7 +13,7 @@ use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; use client::{ModelRequestUsage, RequestUsage}; -use collections::{HashMap, HashSet}; +use collections::HashMap; use feature_flags::{self, FeatureFlagAppExt}; use futures::{FutureExt, StreamExt as _, future::Shared}; use git::repository::DiffType; @@ -23,10 +23,11 @@ use gpui::{ }; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, - LanguageModelToolUseId, MessageContent, ModelRequestLimitReachedError, PaymentRequiredError, - Role, SelectedModel, StopReason, TokenUsage, + LanguageModelExt as _, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, + LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, + ModelRequestLimitReachedError, PaymentRequiredError, Role, SelectedModel, StopReason, + TokenUsage, }; use postage::stream::Stream as _; use project::{ @@ -45,7 +46,7 @@ use std::{ time::{Duration, Instant}, }; use thiserror::Error; -use util::{ResultExt as _, post_inc}; +use util::{ResultExt as _, debug_panic, post_inc}; use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; @@ -960,13 +961,14 @@ impl Thread { model: Arc, ) -> Vec { if model.supports_tools() { - resolve_tool_name_conflicts(self.profile.enabled_tools(cx).as_slice()) + 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: name.into(), description: tool.description(), input_schema, }) @@ -1247,6 +1249,8 @@ impl Thread { self.remaining_turns -= 1; + self.flush_notifications(model.clone(), intent, cx); + let request = self.to_completion_request(model.clone(), intent, cx); self.stream_completion(request, model, intent, window, cx); @@ -1280,6 +1284,7 @@ impl Thread { tool_choice: None, stop: Vec::new(), temperature: AgentSettings::temperature_for_model(&model, cx), + thinking_allowed: true, }; let available_tools = self.available_tools(cx, model.clone()); @@ -1445,6 +1450,7 @@ impl Thread { tool_choice: None, stop: Vec::new(), temperature: AgentSettings::temperature_for_model(model, cx), + thinking_allowed: false, }; for message in &self.messages { @@ -1480,6 +1486,111 @@ impl Thread { request } + /// 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), + }); + } + } + CompletionIntent::ThreadSummarization + | CompletionIntent::ThreadContextSummarization + | CompletionIntent::CreateFile + | CompletionIntent::EditFile + | CompletionIntent::InlineAssist + | CompletionIntent::TerminalInlineAssist + | CompletionIntent::GenerateGitCommitMessage => {} + }; + } + + fn attach_tracked_files_state( + &mut self, + model: Arc, + cx: &mut App, + ) -> Option { + let action_log = self.action_log.read(cx); + + action_log.unnotified_stale_buffers(cx).next()?; + + // Represent notification as a simulated `project_notifications` tool call + let tool_name = Arc::from("project_notifications"); + let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else { + debug_panic!("`project_notifications` tool not found"); + return None; + }; + + if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { + return 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, + ); + + let tool_use_id = + LanguageModelToolUseId::from(format!("project_notifications_{}", self.messages.len())); + + 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, + }; + + 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(), + }; + + self.tool_use + .request_tool_use(tool_message_id, tool_use, tool_use_metadata.clone(), cx); + + 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, + ); + + pending_tool_use + } + pub fn stream_completion( &mut self, request: LanguageModelRequest, @@ -1503,6 +1614,10 @@ impl Thread { prompt_id: prompt_id.clone(), }; + let completion_mode = request + .mode + .unwrap_or(zed_llm_client::CompletionMode::Normal); + self.last_received_chunk_at = Some(Instant::now()); let task = cx.spawn(async move |thread, cx| { @@ -1852,7 +1967,11 @@ impl Thread { .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().saturating_add(1)) + .max( + model + .max_token_count_for_mode(completion_mode) + .saturating_add(1), + ) }); thread.exceeded_window_error = Some(ExceededWindowError { model_id: model.id(), @@ -2386,7 +2505,7 @@ impl Thread { let tool_list = available_tools .iter() - .map(|tool| format!("- {}: {}", tool.name(), tool.description())) + .map(|(name, tool)| format!("- {}: {}", name, tool.description())) .collect::>() .join("\n"); @@ -2400,6 +2519,7 @@ impl Thread { hallucinated_tool_name, Err(anyhow!("Missing tool call: {error_message}")), self.configured_model.as_ref(), + self.completion_mode, ); cx.emit(ThreadEvent::MissingToolUse { @@ -2426,6 +2546,7 @@ impl Thread { 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() @@ -2501,6 +2622,7 @@ impl Thread { tool_name, output, thread.configured_model.as_ref(), + thread.completion_mode, ); thread.tool_finished(tool_use_id, pending_tool_use, false, window, cx); }) @@ -2606,7 +2728,7 @@ impl Thread { .profile .enabled_tools(cx) .iter() - .map(|tool| tool.name()) + .map(|(name, _)| name.clone().into()) .collect(); self.message_feedback.insert(message_id, feedback); @@ -2977,7 +3099,9 @@ impl Thread { return TotalTokenUsage::default(); }; - let max = model.model.max_token_count(); + let max = model + .model + .max_token_count_for_mode(self.completion_mode().into()); let index = self .messages @@ -3004,7 +3128,9 @@ impl Thread { pub fn total_token_usage(&self) -> Option { let model = self.configured_model.as_ref()?; - let max = model.model.max_token_count(); + 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 { @@ -3070,6 +3196,7 @@ impl Thread { tool_name, err, self.configured_model.as_ref(), + self.completion_mode, ); self.tool_finished(tool_use_id.clone(), None, true, window, cx); } @@ -3144,85 +3271,6 @@ struct PendingCompletion { _task: Task<()>, } -/// Resolves tool name conflicts by ensuring all tool names are unique. -/// -/// When multiple tools have the same name, this function applies the following rules: -/// 1. Native tools always keep their original name -/// 2. Context server tools get prefixed with their server ID and an underscore -/// 3. All tool names are truncated to MAX_TOOL_NAME_LENGTH (64 characters) -/// 4. If conflicts still exist after prefixing, the conflicting tools are filtered out -/// -/// Note: This function assumes that built-in tools occur before MCP tools in the tools list. -fn resolve_tool_name_conflicts(tools: &[Arc]) -> Vec<(String, Arc)> { - 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(); - for tool in tools { - let tool_name = resolve_tool_name(tool); - if seen_tool_names.contains(&tool_name) { - debug_assert!( - tool.source() != assistant_tool::ToolSource::Native, - "There are two built-in tools with the same name: {}", - tool_name - ); - duplicated_tool_names.insert(tool_name); - } else { - seen_tool_names.insert(tool_name); - } - } - - if duplicated_tool_names.is_empty() { - return tools - .into_iter() - .map(|tool| (resolve_tool_name(tool), tool.clone())) - .collect(); - } - - 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, tool.clone())); - } - match tool.source() { - assistant_tool::ToolSource::Native => { - // Built-in tools always keep their original name - Some((tool_name, tool.clone())) - } - assistant_tool::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, tool.clone())) - } - } - } - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -3234,11 +3282,13 @@ mod tests { 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 icons::IconName; + use http_client; + use indoc::indoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId, @@ -3566,6 +3616,134 @@ fn main() {{ ); } + #[gpui::test] + 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, + ); + }); + + // 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, + ) + }); + + // Check for the stale buffer warning + thread.update(cx, |thread, cx| { + thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) + }); + + 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"); + }; + + let expected_content = indoc! {"[The following is an auto-generated notification; do not reply] + + These files have changed since the last read: + - code.rs + "}; + assert_eq!(notification_content, expected_content); + + // 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) + }); + + // 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); @@ -3883,148 +4061,6 @@ fn main() {{ }); } - #[gpui::test] - fn test_resolve_tool_name_conflicts() { - use assistant_tool::{Tool, ToolSource}; - - assert_resolve_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - ], - vec!["tool1", "tool2", "tool3"], - ); - - assert_resolve_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["tool1", "tool2", "mcp-1_tool3", "mcp-2_tool3"], - ); - - assert_resolve_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - TestTool::new("tool3", ToolSource::Native), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["tool1", "tool2", "tool3", "mcp-1_tool3", "mcp-2_tool3"], - ); - - // Test that tool with very long name is always truncated - assert_resolve_tool_name_conflicts( - vec![TestTool::new( - "tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-blah-blah", - ToolSource::Native, - )], - vec!["tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-"], - ); - - // Test deduplication of tools with very long names, in this case the mcp server name should be truncated - assert_resolve_tool_name_conflicts( - vec![ - TestTool::new("tool-with-very-very-very-long-name", ToolSource::Native), - TestTool::new( - "tool-with-very-very-very-long-name", - ToolSource::ContextServer { - id: "mcp-with-very-very-very-long-name".into(), - }, - ), - ], - vec![ - "tool-with-very-very-very-long-name", - "mcp-with-very-very-very-long-_tool-with-very-very-very-long-name", - ], - ); - - fn assert_resolve_tool_name_conflicts( - tools: Vec, - expected: Vec>, - ) { - let tools: Vec> = tools - .into_iter() - .map(|t| Arc::new(t) as Arc) - .collect(); - let tools = resolve_tool_name_conflicts(&tools); - assert_eq!(tools.len(), expected.len()); - for (i, expected_name) in expected.into_iter().enumerate() { - let expected_name = expected_name.into(); - let actual_name = &tools[i].0; - assert_eq!( - actual_name, &expected_name, - "Expected '{}' got '{}' at index {}", - expected_name, actual_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) -> IconName { - IconName::Ai - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn needs_confirmation(&self, _input: &serde_json::Value, _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, - ) -> assistant_tool::ToolResult { - assistant_tool::ToolResult { - output: Task::ready(Err(anyhow::anyhow!("No content"))), - card: None, - } - } - } - } - // Helper to create a model that returns errors enum TestError { Overloaded, @@ -5273,6 +5309,14 @@ fn main() {{ 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); }); } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 516151e9ff90dd6dc4a3e4b3dd5eff37522db7f2..0347156cd4df0d8b5d953def949739cab1135025 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -6,7 +6,7 @@ use crate::{ }; use agent_settings::{AgentProfileId, CompletionMode}; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ToolId, ToolWorkingSet}; +use assistant_tool::{Tool, ToolId, ToolWorkingSet}; use chrono::{DateTime, Utc}; use collections::HashMap; use context_server::ContextServerId; @@ -537,8 +537,8 @@ impl ThreadStore { } 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, _| { - tool_working_set.remove(&tool_ids); + tool_working_set.update(cx, |tool_working_set, cx| { + tool_working_set.remove(&tool_ids, cx); }); } } @@ -569,19 +569,17 @@ impl ThreadStore { .log_err() { let tool_ids = tool_working_set - .update(cx, |tool_working_set, _| { - response - .tools - .into_iter() - .map(|tool| { - log::info!("registering context server tool: {:?}", tool.name); - tool_working_set.insert(Arc::new(ContextServerTool::new( + .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, - ))) - }) - .collect::>() + )) as Arc + }), + cx, + ) }) .log_err(); diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index 76de3d20223fcd1c22631029d2040c9109d9ac0d..74c719b4e6cf4ad0743a833f8b1c9fcc9da8b929 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -2,6 +2,7 @@ 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, @@ -11,8 +12,9 @@ use futures::{FutureExt as _, future::Shared}; use gpui::{App, Entity, SharedString, Task, Window}; use icons::IconName; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelRequest, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, Role, + ConfiguredModel, LanguageModel, LanguageModelExt, LanguageModelRequest, + LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseId, Role, }; use project::Project; use std::sync::Arc; @@ -400,6 +402,7 @@ impl ToolUseState { 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); @@ -426,7 +429,10 @@ impl ToolUseState { // Protect from overly large output let tool_output_limit = configured_model - .map(|model| model.model.max_token_count() as usize * BYTES_PER_TOKEN_ESTIMATE) + .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 { diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..549162c5dd16feeb1959ece447d79faa7b7073e4 --- /dev/null +++ b/crates/agent_servers/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "agent_servers" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_servers.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +futures.workspace = true +gpui.workspace = true +paths.workspace = true +project.workspace = true +schemars.workspace = true +serde.workspace = true +settings.workspace = true +util.workspace = true +which.workspace = true +workspace-hack.workspace = true diff --git a/crates/agent_servers/LICENSE-GPL b/crates/agent_servers/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/agent_servers/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs new file mode 100644 index 0000000000000000000000000000000000000000..5d588cd4aea0f863203201de82b0614cc210e615 --- /dev/null +++ b/crates/agent_servers/src/agent_servers.rs @@ -0,0 +1,231 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::{App, AsyncApp, Entity, SharedString}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources, SettingsStore}; +use util::{ResultExt, paths}; + +pub fn init(cx: &mut App) { + AllAgentServersSettings::register(cx); +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AllAgentServersSettings { + gemini: Option, +} + +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AgentServerSettings { + #[serde(flatten)] + command: AgentServerCommand, +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct AgentServerCommand { + #[serde(rename = "command")] + pub path: PathBuf, + #[serde(default)] + pub args: Vec, + pub env: Option>, +} + +pub struct Gemini; + +pub struct AgentServerVersion { + pub current_version: SharedString, + pub supported: bool, +} + +pub trait AgentServer: Send { + fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> impl Future>; + + fn version( + &self, + command: &AgentServerCommand, + ) -> impl Future> + Send; +} + +const GEMINI_ACP_ARG: &str = "--acp"; + +impl AgentServer for Gemini { + async fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result { + let custom_command = cx.read_global(|settings: &SettingsStore, _| { + let settings = settings.get::(None); + settings + .gemini + .as_ref() + .map(|gemini_settings| AgentServerCommand { + path: gemini_settings.command.path.clone(), + args: gemini_settings + .command + .args + .iter() + .cloned() + .chain(std::iter::once(GEMINI_ACP_ARG.into())) + .collect(), + env: gemini_settings.command.env.clone(), + }) + })?; + + if let Some(custom_command) = custom_command { + return Ok(custom_command); + } + + if let Some(path) = find_bin_in_path("gemini", project, cx).await { + return Ok(AgentServerCommand { + path, + args: vec![GEMINI_ACP_ARG.into()], + env: None, + }); + } + + let (fs, node_runtime) = project.update(cx, |project, _| { + (project.fs().clone(), project.node_runtime().cloned()) + })?; + let node_runtime = node_runtime.context("gemini not found on path")?; + + let directory = ::paths::agent_servers_dir().join("gemini"); + fs.create_dir(&directory).await?; + node_runtime + .npm_install_packages(&directory, &[("@google/gemini-cli", "latest")]) + .await?; + let path = directory.join("node_modules/.bin/gemini"); + + Ok(AgentServerCommand { + path, + args: vec![GEMINI_ACP_ARG.into()], + env: None, + }) + } + + async fn version(&self, command: &AgentServerCommand) -> Result { + let version_fut = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--version") + .kill_on_drop(true) + .output(); + + 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)?.into(); + let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG); + + Ok(AgentServerVersion { + current_version, + supported, + }) + } +} + +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; + } + + which_result.log_err() + }) + .await +} + +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() + } +} + +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 value in sources.defaults_and_customizations() { + if value.gemini.is_some() { + settings.gemini = value.gemini.clone(); + } + } + + Ok(settings) + } + + fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} +} diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index f3087765de072f2043fb7f87fd8369a2eab39d25..131cd2dc3f3e4e8967c03cbf1e808ebdeee306cf 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -67,6 +67,8 @@ pub struct AgentSettings { pub model_parameters: Vec, pub preferred_completion_mode: CompletionMode, pub enable_feedback: bool, + pub expand_edit_card: bool, + pub expand_terminal_card: bool, } impl AgentSettings { @@ -291,6 +293,14 @@ pub struct AgentSettingsContent { /// /// 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, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] @@ -441,6 +451,11 @@ impl Settings for AgentSettings { 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, + ); settings .model_parameters diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 070e8eb585016bdeebb5a2358e9f4d35e5624445..72466fe8e7a05f52ac69d79e64a1af3452df089f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,14 +13,14 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = [ - "gpui/test-support", - "language/test-support", -] +test-support = ["gpui/test-support", "language/test-support"] [dependencies] +acp.workspace = true agent.workspace = true +agentic-coding-protocol.workspace = true agent_settings.workspace = true +agent_servers.workspace = true anyhow.workspace = true assistant_context.workspace = true assistant_slash_command.workspace = true @@ -76,6 +76,7 @@ serde_json_lenient.workspace = true settings.workspace = true smol.workspace = true streaming_diff.workspace = true +task.workspace = true telemetry.workspace = true telemetry_events.workspace = true terminal.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc476b1a862b11d964f731cbb0d5ac8e9f100e59 --- /dev/null +++ b/crates/agent_ui/src/acp.rs @@ -0,0 +1,6 @@ +mod completion_provider; +mod message_history; +mod thread_view; + +pub use message_history::MessageHistory; +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 new file mode 100644 index 0000000000000000000000000000000000000000..fca4ae0300bbe82e6c64703de2b2f3b6b101cf20 --- /dev/null +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -0,0 +1,574 @@ +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use anyhow::Result; +use collections::HashMap; +use editor::display_map::CreaseId; +use editor::{CompletionProvider, Editor, ExcerptId}; +use file_icons::FileIcons; +use gpui::{App, Entity, Task, WeakEntity}; +use language::{Buffer, CodeLabel, HighlightId}; +use lsp::CompletionContext; +use parking_lot::Mutex; +use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId}; +use rope::Point; +use text::{Anchor, ToPoint}; +use ui::prelude::*; +use workspace::Workspace; + +use crate::context_picker::MentionLink; +use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files}; + +#[derive(Default)] +pub struct MentionSet { + paths_by_crease_id: HashMap, +} + +impl MentionSet { + pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) { + self.paths_by_crease_id.insert(crease_id, path); + } + + pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option { + self.paths_by_crease_id.get(&crease_id).cloned() + } + + pub fn drain(&mut self) -> impl Iterator { + self.paths_by_crease_id.drain().map(|(id, _)| id) + } +} + +pub struct ContextPickerCompletionProvider { + workspace: WeakEntity, + editor: WeakEntity, + mention_set: Arc>, +} + +impl ContextPickerCompletionProvider { + pub fn new( + mention_set: Arc>, + workspace: WeakEntity, + editor: WeakEntity, + ) -> Self { + Self { + mention_set, + workspace, + editor, + } + } + + fn completion_for_path( + project_path: ProjectPath, + path_prefix: &str, + is_recent: bool, + is_directory: bool, + excerpt_id: ExcerptId, + source_range: Range, + editor: Entity, + mention_set: Arc>, + cx: &App, + ) -> Completion { + let (file_name, directory) = + extract_file_name_and_directory(&project_path.path, path_prefix); + + let label = + build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); + let full_path = if let Some(directory) = directory { + format!("{}{}", directory, file_name) + } else { + file_name.to_string() + }; + + let crease_icon_path = if is_directory { + FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(&full_path), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + let completion_icon_path = if is_recent { + IconName::HistoryRerun.path().into() + } else { + crease_icon_path.clone() + }; + + let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path)); + let new_text_len = new_text.len(); + Completion { + replace_range: source_range.clone(), + new_text, + label, + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(completion_icon_path), + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_icon_path, + file_name, + project_path, + excerpt_id, + source_range.start, + new_text_len - 1, + editor, + mention_set, + )), + } + } +} + +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(); + + label.push_str(&file_name, None); + label.push_str(" ", None); + + if let Some(directory) = directory { + label.push_str(&directory, comment_id); + } + + label.filter_range = 0..label.text().len(); + + label +} + +impl CompletionProvider for ContextPickerCompletionProvider { + fn completions( + &self, + excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: Anchor, + _trigger: CompletionContext, + _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 { + return Task::ready(Ok(Vec::new())); + }; + + let Some(workspace) = self.workspace.upgrade() else { + 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 editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let MentionCompletion { argument, .. } = state; + let query = argument.unwrap_or_else(|| "".to_string()); + + let search_task = search_files(query.clone(), Arc::::default(), &workspace, cx); + + cx.spawn(async move |_, cx| { + let matches = search_task.await; + let Some(editor) = editor.upgrade() else { + return Ok(Vec::new()); + }; + + let completions = cx.update(|cx| { + matches + .into_iter() + .map(|mat| { + let path_match = &mat.mat; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.worktree_id), + path: path_match.path.clone(), + }; + + Self::completion_for_path( + project_path, + &path_match.path_prefix, + mat.is_recent, + path_match.is_dir, + excerpt_id, + source_range.clone(), + editor.clone(), + mention_set.clone(), + 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, + }]) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _menu_is_open: bool, + cx: &mut Context, + ) -> bool { + let buffer = buffer.read(cx); + let position = 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(); + 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) + } else { + false + } + } + + fn sort_completions(&self) -> bool { + false + } + + fn filter_completions(&self) -> bool { + false + } +} + +fn confirm_completion_callback( + crease_icon_path: SharedString, + crease_text: SharedString, + project_path: ProjectPath, + excerpt_id: ExcerptId, + start: Anchor, + content_len: usize, + editor: Entity, + mention_set: Arc>, +) -> Arc bool + Send + Sync> { + Arc::new(move |_, window, cx| { + let crease_text = crease_text.clone(); + let crease_icon_path = crease_icon_path.clone(); + let editor = editor.clone(); + let project_path = project_path.clone(); + let mention_set = mention_set.clone(); + window.defer(cx, move |window, cx| { + let crease_id = crate::context_picker::insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text.clone(), + crease_icon_path, + editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + }); + false + }) +} + +#[derive(Debug, Default, PartialEq)] +struct MentionCompletion { + source_range: Range, + argument: Option, +} + +impl MentionCompletion { + fn try_parse(line: &str, offset_to_line: usize) -> Option { + let last_mention_start = line.rfind('@')?; + if last_mention_start >= line.len() { + return Some(Self::default()); + } + if last_mention_start > 0 + && line + .chars() + .nth(last_mention_start - 1) + .map_or(false, |c| !c.is_whitespace()) + { + return None; + } + + let rest_of_line = &line[last_mention_start + 1..]; + let mut argument = None; + + let mut parts = rest_of_line.split_whitespace(); + let mut end = last_mention_start + 1; + if let Some(argument_text) = parts.next() { + end += argument_text.len(); + argument = Some(argument_text.to_string()); + } + + Some(Self { + source_range: last_mention_start + offset_to_line..end + offset_to_line, + argument, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext}; + use project::{Project, ProjectPath}; + use serde_json::json; + use settings::SettingsStore; + use std::{ops::Deref, rc::Rc}; + use util::path; + use workspace::{AppState, Item}; + + #[test] + fn test_mention_completion_parse() { + assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!( + MentionCompletion::try_parse("Lorem @", 0), + Some(MentionCompletion { + source_range: 6..7, + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @main", 0), + Some(MentionCompletion { + source_range: 6..11, + argument: Some("main".to_string()), + }) + ); + + assert_eq!(MentionCompletion::try_parse("test@", 0), None); + } + + struct AtMentionEditor(Entity); + + impl Item for AtMentionEditor { + type Event = (); + + fn include_in_nav_history() -> bool { + false + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Test".into() + } + } + + impl EventEmitter<()> for AtMentionEditor {} + + impl Focusable for AtMentionEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.0.read(cx).focus_handle(cx).clone() + } + } + + impl Render for AtMentionEditor { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.0.clone().into_any_element() + } + } + + #[gpui::test] + async fn test_context_completion_provider(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!("/dir"), + json!({ + "editor": "", + "a": { + "one.txt": "", + "two.txt": "", + "three.txt": "", + "four.txt": "" + }, + "b": { + "five.txt": "", + "six.txt": "", + "seven.txt": "", + "eight.txt": "", + } + }), + ) + .await; + + 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 worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + + 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"), + ]; + + let mut opened_editors = Vec::new(); + for path in paths { + let buffer = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: Path::new(path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + opened_editors.push(buffer); + } + + 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 mention_set = Arc::new(Mutex::new(MentionSet::default())); + + 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( + mention_set.clone(), + workspace.downgrade(), + editor_entity, + )))); + }); + + cx.simulate_input("Lorem "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem "); + assert!(!editor.has_visible_completions_menu()); + }); + + cx.simulate_input("@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[ + "eight.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "five.txt dir/b/", + "four.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "one.txt dir/a/", + "dir ", + "a dir/", + "four.txt dir/a/", + "one.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "b dir/", + "eight.txt dir/b/", + "five.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "editor dir/" + ] + ); + }); + + // Select and confirm "File" + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + 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), "Lorem [@four.txt](@file:dir/a/four.txt) "); + }); + } + + fn current_completion_labels(editor: &Editor) -> Vec { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| completion.label.text.to_string()) + .collect::>() + } + + pub(crate) fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + editor::init_settings(cx); + }); + } +} diff --git a/crates/agent_ui/src/acp/message_history.rs b/crates/agent_ui/src/acp/message_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0fb1f09908f5505f94777b68b59e18df715c2c4 --- /dev/null +++ b/crates/agent_ui/src/acp/message_history.rs @@ -0,0 +1,87 @@ +pub struct MessageHistory { + items: Vec, + current: Option, +} + +impl Default for MessageHistory { + fn default() -> Self { + MessageHistory { + items: Vec::new(), + current: None, + } + } +} + +impl MessageHistory { + pub fn push(&mut self, message: T) { + self.current.take(); + self.items.push(message); + } + + pub fn reset_position(&mut self) { + self.current.take(); + } + + pub fn prev(&mut self) -> Option<&T> { + if self.items.is_empty() { + return None; + } + + let new_ix = self + .current + .get_or_insert(self.items.len()) + .saturating_sub(1); + + self.current = Some(new_ix); + self.items.get(new_ix) + } + + pub fn next(&mut self) -> Option<&T> { + let current = self.current.as_mut()?; + *current += 1; + + self.items.get(*current).or_else(|| { + self.current.take(); + None + }) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prev_next() { + let mut history = MessageHistory::default(); + + // Test empty history + assert_eq!(history.prev(), None); + assert_eq!(history.next(), None); + + // Add some messages + history.push("first"); + history.push("second"); + history.push("third"); + + // Test prev navigation + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.prev(), Some(&"second")); + assert_eq!(history.prev(), Some(&"first")); + assert_eq!(history.prev(), Some(&"first")); + + assert_eq!(history.next(), Some(&"second")); + + // Test mixed navigation + history.push("fourth"); + assert_eq!(history.prev(), Some(&"fourth")); + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.next(), Some(&"fourth")); + assert_eq!(history.next(), None); + + // Test that push resets navigation + history.prev(); + history.prev(); + history.push("fifth"); + assert_eq!(history.prev(), Some(&"fifth")); + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..7ab395815f734d8f5d0b20eb5b49419361b4627d --- /dev/null +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -0,0 +1,2502 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use agentic_coding_protocol::{self as acp}; +use assistant_tool::ActionLog; +use buffer_diff::BufferDiff; +use collections::{HashMap, HashSet}; +use editor::{ + AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, + EditorStyle, MinimapVisibility, MultiBuffer, PathKey, +}; +use file_icons::FileIcons; +use futures::channel::oneshot; +use gpui::{ + Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, + FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement, + Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + Window, div, linear_color_stop, linear_gradient, list, percentage, point, prelude::*, + pulsating_between, +}; +use language::language_settings::SoftWrap; +use language::{Buffer, Language}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; +use parking_lot::Mutex; +use project::Project; +use settings::Settings as _; +use text::Anchor; +use theme::ThemeSettings; +use ui::{Disclosure, Divider, DividerColor, KeyBinding, Tooltip, prelude::*}; +use util::ResultExt; +use workspace::{CollaboratorId, Workspace}; +use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; + +use ::acp::{ + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff, + LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent, + ToolCallId, ToolCallStatus, +}; + +use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; +use crate::acp::message_history::MessageHistory; +use crate::agent_diff::AgentDiff; +use crate::{AgentDiffPane, Follow, KeepAll, OpenAgentDiff, RejectAll}; + +const RESPONSE_PADDING_X: Pixels = px(19.); + +pub struct AcpThreadView { + workspace: WeakEntity, + project: Entity, + thread_state: ThreadState, + diff_editors: HashMap>, + message_editor: Entity, + message_set_from_history: bool, + _message_editor_subscription: Subscription, + mention_set: Arc>, + last_error: Option>, + list_state: ListState, + auth_task: Option>, + expanded_tool_calls: HashSet, + expanded_thinking_blocks: HashSet<(usize, usize)>, + edits_expanded: bool, + message_history: Rc>>, +} + +enum ThreadState { + Loading { + _task: Task<()>, + }, + Ready { + thread: Entity, + _subscription: [Subscription; 2], + }, + LoadError(LoadError), + Unauthenticated { + thread: Entity, + }, +} + +impl AcpThreadView { + pub fn new( + workspace: WeakEntity, + project: Entity, + message_history: Rc>>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language = Language::new( + language::LanguageConfig { + completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), + ..Default::default() + }, + None, + ); + + let mention_set = Arc::new(Mutex::new(MentionSet::default())); + + let message_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: 4, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text("Message the agent - @ to include files", 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(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace.clone(), + cx.weak_entity(), + )))); + editor.set_context_menu_options(ContextMenuOptions { + min_entries_visible: 12, + max_entries_visible: 12, + placement: Some(ContextMenuPlacement::Above), + }); + editor + }); + + let message_editor_subscription = cx.subscribe(&message_editor, |this, _, event, _| { + if let editor::EditorEvent::BufferEdited = &event { + if !this.message_set_from_history { + this.message_history.borrow_mut().reset_position(); + } + this.message_set_from_history = false; + } + }); + + let mention_set = mention_set.clone(); + + let list_state = ListState::new( + 0, + gpui::ListAlignment::Bottom, + px(2048.0), + cx.processor({ + move |this: &mut Self, 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) + } + }), + ); + + Self { + workspace: workspace.clone(), + project: project.clone(), + thread_state: Self::initial_state(workspace, project, window, cx), + message_editor, + message_set_from_history: false, + _message_editor_subscription: message_editor_subscription, + mention_set, + diff_editors: Default::default(), + list_state: list_state, + last_error: None, + auth_task: None, + expanded_tool_calls: HashSet::default(), + expanded_thinking_blocks: HashSet::default(), + edits_expanded: false, + message_history, + } + } + + fn initial_state( + 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()); + + let load_task = cx.spawn_in(window, async move |this, cx| { + let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await + { + Ok(thread) => thread, + Err(err) => { + this.update(cx, |this, cx| { + this.handle_load_error(err, cx); + cx.notify(); + }) + .log_err(); + return; + } + }; + + let init_response = async { + let resp = thread + .read_with(cx, |thread, _cx| thread.initialize())? + .await?; + anyhow::Ok(resp) + }; + + let result = match init_response.await { + Err(e) => { + let mut cx = cx.clone(); + if e.downcast_ref::().is_some() { + let child_status = thread + .update(&mut cx, |thread, _| thread.child_status()) + .ok() + .flatten(); + if let Some(child_status) = child_status { + match child_status.await { + Ok(_) => Err(e), + Err(e) => Err(e), + } + } else { + Err(e) + } + } else { + Err(e) + } + } + Ok(response) => { + if !response.is_authenticated { + this.update(cx, |this, _| { + this.thread_state = ThreadState::Unauthenticated { thread }; + }) + .ok(); + return; + }; + Ok(()) + } + }; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => { + 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()); + + AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); + + this.thread_state = ThreadState::Ready { + thread, + _subscription: [thread_subscription, action_log_subscription], + }; + + cx.notify(); + } + Err(err) => { + this.handle_load_error(err, cx); + } + }; + }) + .log_err(); + }); + + ThreadState::Loading { _task: load_task } + } + + fn handle_load_error(&mut self, err: anyhow::Error, 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())) + } + cx.notify(); + } + + pub fn thread(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => { + Some(thread) + } + 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(), + } + } + + pub fn cancel(&mut self, cx: &mut Context) { + self.last_error.take(); + + if let Some(thread) = self.thread() { + thread.update(cx, |thread, cx| thread.cancel(cx)).detach(); + } + } + + fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { + self.last_error.take(); + + let mut ix = 0; + let mut chunks: Vec = Vec::new(); + let project = self.project.clone(); + self.message_editor.update(cx, |editor, cx| { + 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() { + if let Some(project_path) = + self.mention_set.lock().path_for_crease_id(crease_id) + { + let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); + if crease_range.start > ix { + chunks.push(acp::UserMessageChunk::Text { + text: text[ix..crease_range.start].to_string(), + }); + } + if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) { + chunks.push(acp::UserMessageChunk::Path { path: abs_path }); + } + ix = crease_range.end; + } + } + + if ix < text.len() { + let last_chunk = text[ix..].trim(); + if !last_chunk.is_empty() { + chunks.push(acp::UserMessageChunk::Text { + text: last_chunk.into(), + }); + } + } + }) + }); + + if chunks.is_empty() { + return; + } + + let Some(thread) = self.thread() else { return }; + let message = acp::SendUserMessageParams { chunks }; + let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx)); + + cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + if let Err(err) = result { + this.last_error = + Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx))) + } + }) + }) + .detach(); + + let mention_set = self.mention_set.clone(); + + self.message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + + self.message_history.borrow_mut().push(message); + } + + fn previous_history_message( + &mut self, + _: &PreviousHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + self.message_set_from_history = Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.borrow_mut().prev(), + window, + cx, + ); + } + + fn next_history_message( + &mut self, + _: &NextHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + self.message_set_from_history = Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.borrow_mut().next(), + window, + cx, + ); + } + + fn set_draft_message( + message_editor: Entity, + mention_set: Arc>, + project: Entity, + message: Option<&acp::SendUserMessageParams>, + window: &mut Window, + cx: &mut Context, + ) -> bool { + cx.notify(); + + let Some(message) = message else { + return false; + }; + + let mut text = String::new(); + let mut mentions = Vec::new(); + + for chunk in &message.chunks { + match chunk { + acp::UserMessageChunk::Text { text: chunk } => { + text.push_str(&chunk); + } + acp::UserMessageChunk::Path { path } => { + let start = text.len(); + let content = MentionPath::new(path).to_string(); + text.push_str(&content); + let end = text.len(); + if let Some(project_path) = + project.read(cx).project_path_for_absolute_path(path, cx) + { + let filename: SharedString = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + mentions.push((start..end, project_path, filename)); + } + } + } + } + + let snapshot = message_editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }); + + for (range, project_path, filename) in mentions { + let crease_icon_path = if project_path.path.is_dir() { + FileIcons::get_folder_icon(false, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + + let anchor = snapshot.anchor_before(range.start); + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + filename, + crease_icon_path, + message_editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + } + + true + } + + 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(); + } + } + + fn open_edited_buffer( + &mut self, + buffer: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread) = self.thread() else { + return; + }; + + let Some(diff) = + AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err() + else { + return; + }; + + diff.update(cx, |diff, cx| { + diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx) + }) + } + + fn handle_thread_event( + &mut self, + thread: &Entity, + event: &AcpThreadEvent, + window: &mut Window, + cx: &mut Context, + ) { + let count = self.list_state.item_count(); + match event { + AcpThreadEvent::NewEntry => { + let index = thread.read(cx).entries().len() - 1; + self.sync_thread_entry_view(index, window, cx); + self.list_state.splice(count..count, 1); + } + AcpThreadEvent::EntryUpdated(index) => { + let index = *index; + self.sync_thread_entry_view(index, window, cx); + self.list_state.splice(index..index + 1, 1); + } + } + cx.notify(); + } + + fn sync_thread_entry_view( + &mut self, + entry_ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else { + return; + }; + + if self.diff_editors.contains_key(&multibuffer.entity_id()) { + return; + } + + 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(), + None, + window, + cx, + ); + editor.set_show_gutter(false, cx); + editor.disable_inline_diagnostics(); + editor.disable_expand_excerpt_buttons(cx); + 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.set_text_style_refinement(TextStyleRefinement { + font_size: Some( + TextSize::Small + .rems(cx) + .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) + .into(), + ), + ..Default::default() + }); + editor + }); + let entity_id = multibuffer.entity_id(); + cx.observe_release(&multibuffer, move |this, _, _| { + this.diff_editors.remove(&entity_id); + }) + .detach(); + + self.diff_editors.insert(entity_id, editor); + } + + fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option> { + let entry = self.thread()?.read(cx).entries().get(entry_ix)?; + entry.diff().map(|diff| diff.multibuffer.clone()) + } + + fn authenticate(&mut self, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + self.last_error.take(); + let authenticate = thread.read(cx).authenticate(); + self.auth_task = Some(cx.spawn_in(window, { + let project = self.project.clone(); + async move |this, cx| { + let result = authenticate.await; + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + this.last_error = Some(cx.new(|cx| { + Markdown::new(format!("Error: {err}").into(), None, None, cx) + })) + } else { + this.thread_state = + Self::initial_state(this.workspace.clone(), project.clone(), window, cx) + } + this.auth_task.take() + }) + .ok(); + } + })); + } + + fn authorize_tool_call( + &mut self, + id: ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { + let Some(thread) = self.thread() else { + return; + }; + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(id, outcome, cx); + }); + cx.notify(); + } + + fn render_entry( + &self, + index: usize, + total_entries: usize, + entry: &AgentThreadEntry, + window: &mut Window, + cx: &Context, + ) -> AnyElement { + match &entry { + AgentThreadEntry::UserMessage(message) => div() + .py_4() + .px_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() + .child(self.render_markdown( + message.content.clone(), + user_message_markdown_style(window, cx), + )), + ) + .into_any(), + AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let style = default_markdown_style(false, window, cx); + let message_body = v_flex() + .w_full() + .gap_2p5() + .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| { + match chunk { + AssistantMessageChunk::Text { chunk } => self + .render_markdown(chunk.clone(), style.clone()) + .into_any_element(), + AssistantMessageChunk::Thought { chunk } => self.render_thinking_block( + index, + chunk_ix, + chunk.clone(), + window, + cx, + ), + } + })) + .into_any(); + + v_flex() + .px_5() + .py_1() + .when(index + 1 == total_entries, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(message_body) + .into_any() + } + AgentThreadEntry::ToolCall(tool_call) => div() + .py_1p5() + .px_5() + .child(self.render_tool_call(index, tool_call, window, cx)) + .into_any(), + } + } + + fn tool_card_header_bg(&self, cx: &Context) -> Hsla { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + + fn tool_card_border_color(&self, cx: &Context) -> Hsla { + cx.theme().colors().border.opacity(0.6) + } + + fn tool_name_font_size(&self) -> Rems { + rems_from_px(13.) + } + + fn render_thinking_block( + &self, + entry_ix: usize, + chunk_ix: usize, + chunk: Entity, + window: &Window, + cx: &Context, + ) -> AnyElement { + let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + + v_flex() + .child( + h_flex() + .id(header_id) + .group("disclosure-header") + .w_full() + .justify_between() + .opacity(0.8) + .hover(|style| style.opacity(1.)) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::ToolBulb) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_size(self.tool_name_font_size()) + .child("Thinking"), + ), + ) + .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| { + 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| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .relative() + .mt_1p5() + .ml(px(7.)) + .pl_4() + .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)), + ), + ) + }) + .into_any_element() + } + + fn render_tool_call( + &self, + entry_ix: usize, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix)); + + let status_icon = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => None, + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + } => 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::Allowed { + status: acp::ToolCallStatus::Finished, + .. + } => None, + ToolCallStatus::Rejected + | ToolCallStatus::Canceled + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Error, + .. + } => Some( + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small) + .into_any_element(), + ), + }; + + let needs_confirmation = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => true, + _ => tool_call + .content + .iter() + .any(|content| matches!(content, ToolCallContent::Diff { .. })), + }; + + let is_collapsible = tool_call.content.is_some() && !needs_confirmation; + let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id); + + let content = if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { confirmation, .. } => { + Some(self.render_tool_call_confirmation( + tool_call.id, + confirmation, + tool_call.content.as_ref(), + window, + cx, + )) + } + ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => { + tool_call.content.as_ref().map(|content| { + div() + .py_1p5() + .child(self.render_tool_call_content(content, window, cx)) + .into_any_element() + }) + } + ToolCallStatus::Rejected => None, + } + } else { + None + }; + + v_flex() + .when(needs_confirmation, |this| { + this.rounded_lg() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + }) + .child( + h_flex() + .id(header_id) + .w_full() + .gap_1() + .justify_between() + .map(|this| { + if needs_confirmation { + this.px_2() + .py_1() + .rounded_t_md() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + } else { + this.opacity(0.8).hover(|style| style.opacity(1.)) + } + }) + .child( + h_flex() + .id("tool-call-header") + .overflow_x_scroll() + .map(|this| { + if needs_confirmation { + this.text_xs() + } else { + this.text_size(self.tool_name_font_size()) + } + }) + .gap_1p5() + .child( + Icon::new(tool_call.icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .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)) + .child(name) + .w_full() + .max_w_full() + .pr_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")) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })) + .into_any_element() + } else { + self.render_markdown( + tool_call.label.clone(), + default_markdown_style(needs_confirmation, window, cx), + ) + .into_any() + }), + ) + .child( + h_flex() + .gap_0p5() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", tool_call.id.0), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + }) + .children(status_icon), + ) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .text_xs() + .when(is_collapsible, |this| { + this.mt_1() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .rounded_lg() + }) + .children(content), + ) + }) + } + + fn render_tool_call_content( + &self, + content: &ToolCallContent, + window: &Window, + cx: &Context, + ) -> AnyElement { + match content { + ToolCallContent::Markdown { markdown } => { + div() + .p_2() + .child(self.render_markdown( + markdown.clone(), + default_markdown_style(false, window, cx), + )) + .into_any_element() + } + ToolCallContent::Diff { + diff: Diff { multibuffer, .. }, + .. + } => self.render_diff_editor(multibuffer), + } + } + + fn render_tool_call_confirmation( + &self, + tool_call_id: ToolCallId, + confirmation: &ToolCallConfirmation, + content: Option<&ToolCallContent>, + window: &Window, + cx: &Context, + ) -> AnyElement { + let confirmation_container = v_flex().mt_1().py_1p5(); + + let button_container = h_flex() + .pt_1p5() + .px_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(self.tool_card_border_color(cx)); + + match confirmation { + ToolCallConfirmation::Edit { description } => confirmation_container + .child( + div() + .px_2() + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow Edits") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Execute { + command, + root_command, + description, + } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(command.clone()).children( + description.clone().map(|description| { + self.render_markdown(description, default_markdown_style(false, window, cx)) + .on_url_click({ + let workspace = self.workspace.clone(); + move |text, window, cx| { + Self::open_link(text, &workspace, window, cx); + } + }) + }), + )) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new( + ("always_allow", tool_call_id.0), + format!("Always Allow {root_command}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Mcp { + server_name, + tool_name: _, + tool_display_name, + description, + } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .child(format!("{server_name} - {tool_display_name}")) + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new( + ("always_allow_server", tool_call_id.0), + format!("Always Allow {server_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer, + cx, + ); + } + })), + ) + .child( + Button::new( + ("always_allow_tool", tool_call_id.0), + format!("Always Allow {tool_display_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllowTool, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Fetch { description, urls } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .gap_1() + .children(urls.iter().map(|url| { + h_flex().child( + Button::new(url.clone(), url) + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click({ + let url = url.clone(); + move |_, _, cx| cx.open_url(&url) + }), + ) + })) + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Other { description } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(self.render_markdown( + description.clone(), + default_markdown_style(false, window, cx), + ))) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + } + } + + fn render_diff_editor(&self, multibuffer: &Entity) -> AnyElement { + v_flex() + .h_full() + .child( + if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) { + editor.clone().into_any_element() + } else { + Empty.into_any() + }, + ) + .into_any() + } + + fn render_gemini_logo(&self) -> AnyElement { + Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element() + } + + fn render_error_gemini_logo(&self) -> AnyElement { + let logo = Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element(); + + 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() + } + + fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child( + if loading { + h_flex() + .justify_center() + .child(self.render_gemini_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_gemini_logo().into_any_element() + } + ) + .child( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new(if loading { + "Connecting to Gemini…" + } else { + "Welcome to Gemini" + }).size(HeadlineSize::Medium)), + ) + .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("Ask questions, edit files, run commands.\nBe specific for the best results.") + ) + .into_any() + } + + fn render_pending_auth_state(&self) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_logo()) + .child( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)), + ) + .into_any() + } + + fn render_error_state(&self, e: &LoadError, cx: &Context) -> AnyElement { + let mut container = v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_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 matches!(e, LoadError::Unsupported { .. }) { + container = + container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click( + cx.listener(|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 command = + "npm install -g @google/gemini-cli@latest".to_string(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId("install".to_string()), + full_label: command.clone(), + label: command.clone(), + command: Some(command.clone()), + args: Vec::new(), + command_label: 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 render_edits_bar( + &self, + thread_entity: &Entity, + window: &mut Window, + cx: &Context, + ) -> Option { + let thread = thread_entity.read(cx); + let action_log = thread.action_log(); + let changed_buffers = action_log.read(cx).changed_buffers(cx); + + if changed_buffers.is_empty() { + 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(); + let expanded = self.edits_expanded; + + v_flex() + .mt_1() + .mx_2() + .bg(bg_edit_files_disclosure) + .border_1() + .border_b_0() + .border_color(cx.theme().colors().border) + .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(self.render_edits_bar_summary( + action_log, + &changed_buffers, + expanded, + pending_edits, + window, + cx, + )) + .when(expanded, |parent| { + parent.child(self.render_edits_bar_files( + action_log, + &changed_buffers, + pending_edits, + cx, + )) + }) + .into_any() + .into() + } + + fn render_edits_bar_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."; + + let focus_handle = self.focus_handle(cx); + + h_flex() + .p_1() + .justify_between() + .when(expanded, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .id("edits-container") + .cursor_pointer() + .w_full() + .gap_1() + .child(Disclosure::new("edits-disclosure", expanded)) + .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.edits_expanded = !this.edits_expanded; + cx.notify(); + })), + ) + .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(|_, _, window, cx| { + window.dispatch_action(OpenAgentDiff.boxed_clone(), 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({ + let action_log = action_log.clone(); + cx.listener(move |_, _, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.reject_all_edits(cx).detach(); + }) + }) + }), + ) + .child( + Button::new("keep-all-changes", "Keep 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({ + let action_log = action_log.clone(); + cx.listener(move |_, _, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.keep_all_edits(cx); + }) + }) + }), + ), + ) + } + + fn render_edits_bar_files( + &self, + action_log: &Entity, + changed_buffers: &BTreeMap, Entity>, + pending_edits: bool, + cx: &Context, + ) -> Div { + let editor_bg_color = cx.theme().colors().editor_background; + + 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(cx.theme().colors().border).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.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .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.open_edited_buffer(&buffer, 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(); + let action_log = action_log.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log + .reject_edits_in_ranges( + buffer.clone(), + vec![Anchor::MIN..Anchor::MAX], + cx, + ) + .detach_and_log_err(cx); + }) + } + }), + ) + .child( + Button::new("keep-file", "Keep") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.keep_edits_in_range( + buffer.clone(), + Anchor::MIN..Anchor::MAX, + cx, + ); + }) + } + }), + ), + ) + .child( + div() + .id("gradient-overlay") + .absolute() + .h_full() + .w_12() + .top_0() + .bottom_0() + .right(px(152.)) + .bg(overlay_gradient), + ); + + Some(element) + }, + )) + } + + fn render_message_editor(&mut self, cx: &mut Context) -> AnyElement { + 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.message_editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + .into_any() + } + + 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); + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled(self.thread().is_none() || is_editor_empty) + .on_click(cx.listener(|this, _, window, cx| { + this.chat(&Chat, window, cx); + })) + .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")) + }) + .into_any_element() + } else { + IconButton::new("stop-generation", IconName::StopFilled) + .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(cx.listener(|this, _event, _, cx| this.cancel(cx))) + .into_any_element() + } + } + + 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); + + 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| { + 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_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + let workspace = self.workspace.clone(); + MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { + Self::open_link(text, &workspace, window, cx); + }) + } + + fn open_link( + url: SharedString, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let Some(workspace) = workspace.upgrade() else { + cx.open_url(&url); + return; + }; + + if let Some(mention_path) = MentionPath::try_parse(&url) { + workspace.update(cx, |workspace, cx| { + let project = workspace.project(); + let Some((path, entry)) = project.update(cx, |project, cx| { + let path = project.find_project_path(mention_path.path(), cx)?; + let entry = project.entry_for_path(&path, cx)?; + Some((path, entry)) + }) 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); + } + }) + } else { + cx.open_url(&url); + } + } + + fn open_tool_call_location( + &self, + entry_ix: usize, + location_ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let location = self + .thread()? + .read(cx) + .entries() + .get(entry_ix)? + .locations()? + .get(location_ix)?; + + let project_path = self + .project + .read(cx) + .find_project_path(&location.path, cx)?; + + let open_task = self + .workspace + .update(cx, |worskpace, cx| { + worskpace.open_path(project_path, None, true, window, cx) + }) + .log_err()?; + + window + .spawn(cx, async move |cx| { + let item = open_task.await?; + + let Some(active_editor) = item.downcast::() else { + return anyhow::Ok(()); + }; + + 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]); + }) + } + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + None + } + + pub fn open_thread_as_markdown( + &self, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let markdown_language_task = workspace + .read(cx) + .app_state() + .languages + .language_for_name("Markdown"); + + let (thread_summary, markdown) = match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => { + let thread = thread.read(cx); + (thread.title().to_string(), thread.to_markdown(cx)) + } + ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())), + }; + + window.spawn(cx, async move |cx| { + let markdown_language = markdown_language_task.await?; + + workspace.update_in(cx, |workspace, window, cx| { + 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(()) + }) + } + + fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.scroll_to(ListOffset::default()); + cx.notify(); + } +} + +impl Focusable for AcpThreadView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.message_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Open Thread as Markdown")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace.upgrade() { + this.open_thread_as_markdown(workspace, window, cx) + .detach_and_log_err(cx); + } + })); + + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Top")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_top(cx); + })); + + v_flex() + .size_full() + .key_context("AcpThread") + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::previous_history_message)) + .on_action(cx.listener(Self::next_history_message)) + .on_action(cx.listener(Self::open_agent_diff)) + .child(match &self.thread_state { + ThreadState::Unauthenticated { .. } => v_flex() + .p_2() + .flex_1() + .items_center() + .justify_center() + .child(self.render_pending_auth_state()) + .child(h_flex().mt_1p5().justify_center().child( + Button::new("sign-in", "Sign in to Gemini").on_click( + cx.listener(|this, _, window, cx| this.authenticate(window, cx)), + ), + )), + ThreadState::Loading { .. } => { + v_flex().flex_1().child(self.render_empty_state(true, cx)) + } + ThreadState::LoadError(e) => v_flex() + .p_2() + .flex_1() + .items_center() + .justify_center() + .child(self.render_error_state(e, cx)), + ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| { + if self.list_state.item_count() > 0 { + this.child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .child( + h_flex() + .group("controls") + .mt_1() + .mr_1() + .py_2() + .px(RESPONSE_PADDING_X) + .opacity(0.4) + .hover(|style| style.opacity(1.)) + .gap_1() + .flex_wrap() + .justify_end() + .child(open_as_markdown) + .child(scroll_to_top) + .into_any_element(), + ) + .children(match thread.read(cx).status() { + ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None, + ThreadStatus::Generating => div() + .px_5() + .py_2() + .child(LoadingLabel::new("").size(LabelSize::Small)) + .into(), + }) + .children(self.render_edits_bar(&thread, window, cx)) + } else { + this.child(self.render_empty_state(false, cx)) + } + }), + }) + .when_some(self.last_error.clone(), |el, error| { + el.child( + div() + .p_2() + .text_xs() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().status().error_background) + .child( + self.render_markdown(error, default_markdown_style(false, window, cx)), + ), + ) + }) + .child( + v_flex() + .p_2() + .pt_3() + .gap_1() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .child(self.render_message_editor(cx)) + .child( + h_flex() + .justify_between() + .child(self.render_follow_toggle(cx)) + .child(self.render_send_button(cx)), + ), + ) + } +} + +fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let mut style = default_markdown_style(false, 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.link_callback = Some(Rc::new(move |url, cx| { + if MentionPath::try_parse(url).is_some() { + let colors = cx.theme().colors(); + Some(TextStyleRefinement { + background_color: Some(colors.element_background), + ..Default::default() + }) + } else { + None + } + })); + style +} + +fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + + let buffer_font_size = TextSize::Small.rems(cx); + + let mut text_style = window.text_style(); + let line_height = buffer_font_size * 1.75; + + let font_family = if buffer_font { + theme_settings.buffer_font.family.clone() + } else { + theme_settings.ui_font.family.clone() + }; + + let font_size = if buffer_font { + TextSize::Small.rems(cx) + } else { + TextSize::Default.rems(cx) + }; + + 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), + ..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.)))), + }, + 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())), + }, + 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.))), + }, + border_color: Some(colors.border_variant), + 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() + }, + ..Default::default() + } +} diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index fa6d3144b519aa823171a9e86971d5ed96cf7b06..383729017a1635e4301fa50d587f70940543130f 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -1,9 +1,7 @@ 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, AnimatedLabel, ContextPill, -}; +use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill}; use crate::{AgentPanel, ModelUsageContext}; use agent::{ ContextStore, LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, TextThreadStore, @@ -789,6 +787,15 @@ impl ActiveThread { .unwrap() } }); + + 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, @@ -836,6 +843,10 @@ impl ActiveThread { } } + if let Some(subscription) = workspace_subscription { + this._subscriptions.push(subscription); + } + this } @@ -1026,6 +1037,7 @@ impl ActiveThread { } } 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( @@ -1042,6 +1054,7 @@ impl ActiveThread { 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| { @@ -1461,6 +1474,7 @@ impl ActiveThread { &configured_model.model, cx, ), + thinking_allowed: true, }; Some(configured_model.model.count_tokens(request, cx)) @@ -1818,7 +1832,7 @@ impl ActiveThread { .my_3() .mx_5() .when(is_generating_stale || message.is_hidden, |this| { - this.child(AnimatedLabel::new("").size(LabelSize::Small)) + this.child(LoadingLabel::new("").size(LabelSize::Small)) }) }); @@ -2580,11 +2594,11 @@ impl ActiveThread { h_flex() .gap_1p5() .child( - Icon::new(IconName::LightBulb) - .size(IconSize::XSmall) + Icon::new(IconName::ToolBulb) + .size(IconSize::Small) .color(Color::Muted), ) - .child(AnimatedLabel::new("Thinking").size(LabelSize::Small)), + .child(LoadingLabel::new("Thinking").size(LabelSize::Small)), ) .child( h_flex() @@ -2994,7 +3008,7 @@ impl ActiveThread { .overflow_x_scroll() .child( Icon::new(tool_use.icon) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(Color::Muted), ) .child( @@ -3153,7 +3167,7 @@ impl ActiveThread { .border_color(self.tool_card_border_color(cx)) .rounded_b_lg() .child( - AnimatedLabel::new("Waiting for Confirmation").size(LabelSize::Small) + LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small) ) .child( h_flex() diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 4aa9c3fc38b2555e2675d668aa534d9c0125da7e..8bfdd507611112b2930fd07270667050796533e3 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -26,8 +26,8 @@ use project::{ }; use settings::{Settings, update_settings_file}; use ui::{ - ContextMenu, Disclosure, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, - Switch, SwitchColor, Tooltip, prelude::*, + ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, + Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::Workspace; @@ -172,19 +172,29 @@ impl AgentConfiguration { .unwrap_or(false); v_flex() - .py_2() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border.opacity(0.6)) + .when(is_expanded, |this| this.mb_2()) + .child( + div() + .opacity(0.6) + .px_2() + .child(Divider::horizontal().color(DividerColor::Border)), + ) .child( h_flex() + .map(|this| { + if is_expanded { + this.mt_2().mb_1() + } else { + this.my_2() + } + }) .w_full() - .gap_1() .justify_between() .child( h_flex() .id(provider_id_string.clone()) .cursor_pointer() + .px_2() .py_0p5() .w_full() .justify_between() @@ -247,12 +257,16 @@ impl AgentConfiguration { ) }), ) - .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}", - ))), - }) + .child( + div() + .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( @@ -262,12 +276,11 @@ impl AgentConfiguration { let providers = LanguageModelRegistry::read_global(cx).providers(); v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .border_b_1() - .border_color(cx.theme().colors().border) .child( v_flex() + .p(DynamicSpacing::Base16.rems(cx)) + .pr(DynamicSpacing::Base20.rems(cx)) + .pb_0() .mb_2p5() .gap_0p5() .child(Headline::new("LLM Providers")) @@ -276,10 +289,15 @@ impl AgentConfiguration { .color(Color::Muted), ), ) - .children( - providers - .into_iter() - .map(|provider| self.render_provider_configuration_block(&provider, cx)), + .child( + div() + .pl(DynamicSpacing::Base08.rems(cx)) + .pr(DynamicSpacing::Base20.rems(cx)) + .children( + providers.into_iter().map(|provider| { + self.render_provider_configuration_block(&provider, cx) + }), + ), ) } @@ -418,7 +436,7 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).all_server_ids().clone(); + let context_server_ids = self.context_server_store.read(cx).configured_server_ids(); v_flex() .p(DynamicSpacing::Base16.rems(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 299f3cee34b1c7635c3c0a8f46a52cc730993b01..9e5f6e09c82489dd4ccdc89f188e962ceeec596d 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 @@ -379,6 +379,14 @@ impl ConfigureContextServerModal { }; self.state = State::Waiting; + + let existing_server = self.context_server_store.read(cx).get_running_server(&id); + if existing_server.is_some() { + self.context_server_store.update(cx, |store, cx| { + store.stop_server(&id, cx).log_err(); + }); + } + let wait_for_context_server_task = wait_for_context_server(&self.context_server_store, id.clone(), cx); cx.spawn({ @@ -399,13 +407,21 @@ impl ConfigureContextServerModal { }) .detach(); - // 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 settings_changed = + ProjectSettings::get_global(cx).context_servers.get(&id.0) != Some(&settings); + + if settings_changed { + // 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); + }); }); - }); + } else if let Some(existing_server) = existing_server { + self.context_server_store + .update(cx, |store, cx| store.start_server(existing_server, cx)); + } } fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { @@ -724,7 +740,9 @@ fn wait_for_context_server( }); cx.spawn(async move |_cx| { - let result = rx.await.unwrap(); + let result = rx + .await + .map_err(|_| Arc::from("Context server store was dropped"))?; drop(subscription); result }) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 1a0f3ff27d83a98d343985b3f827aab26afd192a..31fb0dd69fbbd133888eb26d14643d816c810554 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,7 +1,9 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; -use agent::{Thread, ThreadEvent}; +use acp::{AcpThread, AcpThreadEvent}; +use agent::{Thread, ThreadEvent, ThreadSummary}; use agent_settings::AgentSettings; use anyhow::Result; +use assistant_tool::ActionLog; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ @@ -41,16 +43,108 @@ use zed_actions::assistant::ToggleFocus; pub struct AgentDiffPane { multibuffer: Entity, editor: Entity, - thread: Entity, + thread: AgentDiffThread, focus_handle: FocusHandle, workspace: WeakEntity, title: SharedString, _subscriptions: Vec, } +#[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 { + match self { + AgentDiffThread::Native(thread) => thread.read(cx).summary().clone(), + AgentDiffThread::AcpThread(thread) => ThreadSummary::Ready(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::ThreadStatus::Generating + } + } + } + + 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()) + } + } + } +} + +impl From> for AgentDiffThread { + fn from(entity: Entity) -> Self { + AgentDiffThread::Native(entity) + } +} + +impl From> for AgentDiffThread { + fn from(entity: Entity) -> Self { + AgentDiffThread::AcpThread(entity) + } +} + +#[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) + } +} + impl AgentDiffPane { pub fn deploy( - thread: Entity, + thread: impl Into, workspace: WeakEntity, window: &mut Window, cx: &mut App, @@ -61,14 +155,16 @@ impl AgentDiffPane { } pub fn deploy_in_workspace( - thread: Entity, + thread: impl Into, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) -> Entity { + let thread = thread.into(); let existing_diff = workspace .items_of_type::(cx) .find(|diff| diff.read(cx).thread == thread); + if let Some(existing_diff) = existing_diff { workspace.activate_item(&existing_diff, true, true, window, cx); existing_diff @@ -81,7 +177,7 @@ impl AgentDiffPane { } pub fn new( - thread: Entity, + thread: AgentDiffThread, workspace: WeakEntity, window: &mut Window, cx: &mut Context, @@ -89,7 +185,7 @@ impl AgentDiffPane { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let project = thread.read(cx).project().clone(); + let project = thread.project(cx).clone(); let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); @@ -100,16 +196,27 @@ impl AgentDiffPane { editor }); - let action_log = thread.read(cx).action_log().clone(); + let action_log = thread.action_log(cx).clone(); + let mut this = Self { - _subscriptions: vec![ - cx.observe_in(&action_log, window, |this, _action_log, window, cx| { - this.update_excerpts(window, cx) - }), - cx.subscribe(&thread, |this, _thread, event, cx| { - this.handle_thread_event(event, cx) - }), - ], + _subscriptions: [ + Some( + 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, + }, + ] + .into_iter() + .flatten() + .collect(), title: SharedString::default(), multibuffer, editor, @@ -123,8 +230,7 @@ impl AgentDiffPane { } fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context) { - let thread = self.thread.read(cx); - let changed_buffers = thread.action_log().read(cx).changed_buffers(cx); + let changed_buffers = self.thread.action_log(cx).read(cx).changed_buffers(cx); let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); for (buffer, diff_handle) in changed_buffers { @@ -211,7 +317,7 @@ impl AgentDiffPane { } fn update_title(&mut self, cx: &mut Context) { - let new_title = self.thread.read(cx).summary().unwrap_or("Agent Changes"); + let new_title = self.thread.summary(cx).unwrap_or("Agent Changes"); if new_title != self.title { self.title = new_title; cx.emit(EditorEvent::TitleChanged); @@ -275,14 +381,15 @@ impl AgentDiffPane { fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { self.thread - .update(cx, |thread, cx| thread.keep_all_edits(cx)); + .action_log(cx) + .update(cx, |action_log, cx| action_log.keep_all_edits(cx)) } } fn keep_edits_in_selection( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut Context, ) { @@ -297,7 +404,7 @@ fn keep_edits_in_selection( fn reject_edits_in_selection( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut Context, ) { @@ -311,7 +418,7 @@ fn reject_edits_in_selection( fn keep_edits_in_ranges( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &Entity, + thread: &AgentDiffThread, ranges: Vec>, window: &mut Window, cx: &mut Context, @@ -326,8 +433,8 @@ fn keep_edits_in_ranges( for hunk in &diff_hunks_in_ranges { let buffer = multibuffer.read(cx).buffer(hunk.buffer_id); if let Some(buffer) = buffer { - thread.update(cx, |thread, cx| { - thread.keep_edits_in_range(buffer, hunk.buffer_range.clone(), cx) + thread.action_log(cx).update(cx, |action_log, cx| { + action_log.keep_edits_in_range(buffer, hunk.buffer_range.clone(), cx) }); } } @@ -336,7 +443,7 @@ fn keep_edits_in_ranges( fn reject_edits_in_ranges( editor: &mut Editor, buffer_snapshot: &MultiBufferSnapshot, - thread: &Entity, + thread: &AgentDiffThread, ranges: Vec>, window: &mut Window, cx: &mut Context, @@ -362,8 +469,9 @@ fn reject_edits_in_ranges( for (buffer, ranges) in ranges_by_buffer { thread - .update(cx, |thread, cx| { - thread.reject_edits_in_ranges(buffer, ranges, cx) + .action_log(cx) + .update(cx, |action_log, cx| { + action_log.reject_edits_in_ranges(buffer, ranges, cx) }) .detach_and_log_err(cx); } @@ -461,7 +569,7 @@ impl Item for AgentDiffPane { } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { - let summary = self.thread.read(cx).summary().unwrap_or("Agent Changes"); + let summary = self.thread.summary(cx).unwrap_or("Agent Changes"); Label::new(format!("Review: {}", summary)) .color(if params.selected { Color::Default @@ -641,7 +749,7 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls(thread: &Entity) -> editor::RenderDiffHunkControlsFn { +fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControlsFn { let thread = thread.clone(); Arc::new( @@ -676,7 +784,7 @@ fn render_diff_hunk_controls( hunk_range: Range, is_created_file: bool, line_height: Pixels, - thread: &Entity, + thread: &AgentDiffThread, editor: &Entity, window: &mut Window, cx: &mut App, @@ -1112,11 +1220,8 @@ impl Render for AgentDiffToolbar { return Empty.into_any(); }; - let has_pending_edit_tool_use = agent_diff - .read(cx) - .thread - .read(cx) - .has_pending_edit_tool_uses(); + let has_pending_edit_tool_use = + agent_diff.read(cx).thread.has_pending_edit_tool_uses(cx); if has_pending_edit_tool_use { return div().px_2().child(spinner_icon).into_any(); @@ -1187,8 +1292,8 @@ pub enum EditorState { } struct WorkspaceThread { - thread: WeakEntity, - _thread_subscriptions: [Subscription; 2], + thread: WeakAgentDiffThread, + _thread_subscriptions: (Subscription, Subscription), singleton_editors: HashMap, HashMap, Subscription>>, _settings_subscription: Subscription, _workspace_subscription: Option, @@ -1212,23 +1317,23 @@ impl AgentDiff { pub fn set_active_thread( workspace: &WeakEntity, - thread: &Entity, + thread: impl Into, window: &mut Window, cx: &mut App, ) { Self::global(cx).update(cx, |this, cx| { - this.register_active_thread_impl(workspace, thread, window, cx); + this.register_active_thread_impl(workspace, thread.into(), window, cx); }); } fn register_active_thread_impl( &mut self, workspace: &WeakEntity, - thread: &Entity, + thread: AgentDiffThread, window: &mut Window, cx: &mut Context, ) { - let action_log = thread.read(cx).action_log().clone(); + let action_log = thread.action_log(cx).clone(); let action_log_subscription = cx.observe_in(&action_log, window, { let workspace = workspace.clone(); @@ -1237,17 +1342,25 @@ impl AgentDiff { } }); - let thread_subscription = cx.subscribe_in(&thread, window, { - let workspace = workspace.clone(); - move |this, _thread, event, window, cx| { - this.handle_thread_event(&workspace, event, window, cx) - } - }); + 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, { + let workspace = workspace.clone(); + move |this, thread, event, window, cx| { + this.handle_acp_thread_event(&workspace, thread, event, window, cx) + } + }), + }; 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]; + workspace_thread._thread_subscriptions = (action_log_subscription, thread_subscription); self.update_reviewing_editors(&workspace, window, cx); return; } @@ -1272,7 +1385,7 @@ impl AgentDiff { workspace.clone(), WorkspaceThread { thread: thread.downgrade(), - _thread_subscriptions: [action_log_subscription, thread_subscription], + _thread_subscriptions: (action_log_subscription, thread_subscription), singleton_editors: HashMap::default(), _settings_subscription: settings_subscription, _workspace_subscription: workspace_subscription, @@ -1319,7 +1432,7 @@ impl AgentDiff { fn register_review_action( workspace: &mut Workspace, - review: impl Fn(&Entity, &Entity, &mut Window, &mut App) -> PostReviewState + review: impl Fn(&Entity, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState + 'static, this: &Entity, ) { @@ -1338,7 +1451,7 @@ impl AgentDiff { }); } - fn handle_thread_event( + fn handle_native_thread_event( &mut self, workspace: &WeakEntity, event: &ThreadEvent, @@ -1380,6 +1493,40 @@ impl AgentDiff { } } + fn handle_acp_thread_event( + &mut self, + workspace: &WeakEntity, + thread: &Entity, + event: &AcpThreadEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + AcpThreadEvent::NewEntry => { + if thread + .read(cx) + .entries() + .last() + .and_then(|entry| entry.diff()) + .is_some() + { + self.update_reviewing_editors(workspace, window, cx); + } + } + AcpThreadEvent::EntryUpdated(ix) => { + if thread + .read(cx) + .entries() + .get(*ix) + .and_then(|entry| entry.diff()) + .is_some() + { + self.update_reviewing_editors(workspace, window, cx); + } + } + } + } + fn handle_workspace_event( &mut self, workspace: &Entity, @@ -1485,7 +1632,7 @@ impl AgentDiff { return; }; - let action_log = thread.read(cx).action_log(); + let action_log = thread.action_log(cx); let changed_buffers = action_log.read(cx).changed_buffers(cx); let mut unaffected = self.reviewing_editors.clone(); @@ -1510,7 +1657,7 @@ impl AgentDiff { multibuffer.add_diff(diff_handle.clone(), cx); }); - let new_state = if thread.read(cx).is_generating() { + let new_state = if thread.is_generating(cx) { EditorState::Generating } else { EditorState::Reviewing @@ -1606,7 +1753,7 @@ impl AgentDiff { fn keep_all( editor: &Entity, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1626,7 +1773,7 @@ impl AgentDiff { fn reject_all( editor: &Entity, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1646,7 +1793,7 @@ impl AgentDiff { fn keep( editor: &Entity, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1659,7 +1806,7 @@ impl AgentDiff { fn reject( editor: &Entity, - thread: &Entity, + thread: &AgentDiffThread, window: &mut Window, cx: &mut App, ) -> PostReviewState { @@ -1682,7 +1829,7 @@ impl AgentDiff { fn review_in_active_editor( &mut self, workspace: &mut Workspace, - review: impl Fn(&Entity, &Entity, &mut Window, &mut App) -> PostReviewState, + review: impl Fn(&Entity, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState, window: &mut Window, cx: &mut Context, ) -> Option>> { @@ -1703,7 +1850,7 @@ impl AgentDiff { 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.read(cx).action_log().read(cx).changed_buffers(cx); + 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); @@ -1801,8 +1948,9 @@ mod tests { }) .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 thread = + AgentDiffThread::Native(thread_store.update(cx, |store, cx| store.create_thread(cx))); + let action_log = cx.read(|cx| thread.action_log(cx)); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); @@ -1988,8 +2136,9 @@ mod tests { }); // Set the active thread + let thread = AgentDiffThread::Native(thread); cx.update(|window, cx| { - AgentDiff::set_active_thread(&workspace.downgrade(), &thread, window, cx) + AgentDiff::set_active_thread(&workspace.downgrade(), thread.clone(), window, cx) }); let buffer1 = project diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5f58e0bd8d1a6c3c7faed310898f4ee858afb4f8..18e43dd51eaca2699bf6feeddd20386b145da628 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::ops::Range; use std::path::Path; use std::rc::Rc; @@ -7,12 +8,15 @@ use std::time::Duration; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use crate::NewAcpThread; +use crate::agent_diff::AgentDiffThread; use crate::language_model_selector::ToggleModelSelector; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, + acp::AcpThreadView, active_thread::{self, ActiveThread, ActiveThreadEvent}, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, agent_diff::AgentDiff, @@ -38,6 +42,7 @@ use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; use client::{UserStore, zed_urls}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use feature_flags::{self, FeatureFlagAppExt}; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, @@ -109,6 +114,12 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); } }) + .register_action(|workspace, _: &NewAcpThread, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx)); + } + }) .register_action(|workspace, action: &OpenRulesLibrary, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -125,7 +136,8 @@ pub fn init(cx: &mut App) { let thread = thread.read(cx).thread().clone(); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -188,6 +200,9 @@ enum ActiveView { message_editor: Entity, _subscriptions: Vec, }, + AcpThread { + thread_view: Entity, + }, TextThread { context_editor: Entity, title_editor: Entity, @@ -207,7 +222,9 @@ enum WhichFontSize { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont, + ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => { + WhichFontSize::AgentFont + } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::Configuration => WhichFontSize::None, } @@ -238,6 +255,7 @@ impl ActiveView { thread.scroll_to_bottom(cx); }); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -416,11 +434,14 @@ pub struct AgentPanel { configuration_subscription: Option, local_timezone: UtcOffset, active_view: ActiveView, + acp_message_history: + Rc>>, previous_view: Option, history_store: Entity, history: Entity, hovered_recent_history_item: Option, - assistant_dropdown_menu_handle: PopoverMenuHandle, + new_thread_menu_handle: PopoverMenuHandle, + agent_panel_menu_handle: PopoverMenuHandle, assistant_navigation_menu_handle: PopoverMenuHandle, assistant_navigation_menu: Option>, width: Option, @@ -607,7 +628,7 @@ impl AgentPanel { } }; - AgentDiff::set_active_thread(&workspace, &thread, window, cx); + AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); let weak_panel = weak_self.clone(); @@ -653,7 +674,8 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} }, @@ -680,10 +702,12 @@ impl AgentPanel { .unwrap(), inline_assist_context_store, previous_view: None, + acp_message_history: Default::default(), history_store: history_store.clone(), history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), hovered_recent_history_item: None, - assistant_dropdown_menu_handle: PopoverMenuHandle::default(), + new_thread_menu_handle: PopoverMenuHandle::default(), + agent_panel_menu_handle: PopoverMenuHandle::default(), assistant_navigation_menu_handle: PopoverMenuHandle::default(), assistant_navigation_menu: None, width: None, @@ -733,6 +757,9 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } + ActiveView::AcpThread { thread_view, .. } => { + thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx)); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -740,18 +767,18 @@ impl AgentPanel { fn active_message_editor(&self) -> Option<&Entity> { match &self.active_view { ActiveView::Thread { message_editor, .. } => Some(message_editor), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, } } fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context) { - // Preserve chat box text when using creating new thread from summary' - let preserved_text = if action.from_thread_id.is_some() { - self.active_message_editor() - .map(|editor| editor.read(cx).get_text(cx).trim().to_string()) - } else { - None - }; + // 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 @@ -823,7 +850,7 @@ impl AgentPanel { 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, window, cx); + AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); } fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { @@ -862,6 +889,37 @@ impl AgentPanel { context_editor.focus_handle(cx).focus(window); } + fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let message_history = self.acp_message_history.clone(); + + cx.spawn_in(window, async move |this, cx| { + let thread_view = cx.new_window_entity(|window, cx| { + crate::acp::AcpThreadView::new( + workspace.clone(), + project, + message_history, + window, + cx, + ) + })?; + this.update_in(cx, |this, window, cx| { + this.set_active_view( + ActiveView::AcpThread { + thread_view: thread_view.clone(), + }, + window, + cx, + ); + }) + .log_err(); + + anyhow::Ok(()) + }) + .detach(); + } + fn deploy_rules_library( &mut self, action: &OpenRulesLibrary, @@ -994,6 +1052,7 @@ impl AgentPanel { cx, ) }); + let message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -1012,7 +1071,7 @@ impl AgentPanel { 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, 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) { @@ -1025,6 +1084,9 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => { message_editor.focus_handle(cx).focus(window); } + ActiveView::AcpThread { thread_view } => { + thread_view.focus_handle(cx).focus(window); + } ActiveView::TextThread { context_editor, .. } => { context_editor.focus_handle(cx).focus(window); } @@ -1052,7 +1114,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.assistant_dropdown_menu_handle.toggle(window, cx); + self.agent_panel_menu_handle.toggle(window, cx); } pub fn increase_font_size( @@ -1140,11 +1202,19 @@ impl AgentPanel { let thread = thread.read(cx).thread().clone(); self.workspace .update(cx, |workspace, cx| { - AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx) + AgentDiffPane::deploy_in_workspace( + AgentDiffThread::Native(thread), + workspace, + window, + cx, + ) }) .log_err(); } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} } } @@ -1197,6 +1267,13 @@ impl AgentPanel { ) .detach_and_log_err(cx); } + ActiveView::AcpThread { thread_view } => { + thread_view + .update(cx, |thread_view, cx| { + thread_view.open_thread_as_markdown(workspace, window, cx) + }) + .detach_and_log_err(cx); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1351,7 +1428,8 @@ impl AgentPanel { } }) } - _ => {} + ActiveView::AcpThread { .. } => {} + ActiveView::History | ActiveView::Configuration => {} } if current_is_special && !new_is_special { @@ -1365,6 +1443,8 @@ impl AgentPanel { self.active_view = new_view; } + self.acp_message_history.borrow_mut().reset_position(); + self.focus_handle(cx).focus(window); } @@ -1437,6 +1517,7 @@ 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::AcpThread { thread_view, .. } => thread_view.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1593,6 +1674,9 @@ impl AgentPanel { .into_any_element(), } } + ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx)) + .truncate() + .into_any_element(), ActiveView::TextThread { title_editor, context_editor, @@ -1727,32 +1811,26 @@ impl AgentPanel { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, }; - let agent_extra_menu = PopoverMenu::new("agent-options-menu") + let new_thread_menu = PopoverMenu::new("new_thread_menu") .trigger_with_tooltip( - IconButton::new("agent-options-menu", IconName::Ellipsis) - .icon_size(IconSize::Small), - { - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Toggle Agent Menu", - &ToggleOptionsMenu, - &focus_handle, - window, - cx, - ) - } - }, + IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), + Tooltip::text("New Thread…"), ) .anchor(Corner::TopRight) - .with_handle(self.assistant_dropdown_menu_handle.clone()) + .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| { let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu + .when(cx.has_flag::(), |this| { + this.header("Zed Agent") + }) .action("New Thread", NewThread::default().boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone()) .when_some(active_thread, |this, active_thread| { @@ -1768,21 +1846,36 @@ impl AgentPanel { this } }) - .separator(); + .when(cx.has_flag::(), |this| { + this.separator() + .header("External Agents") + .action("New Gemini Thread", NewAcpThread.boxed_clone()) + }); + menu + })) + }); - menu = menu - .header("MCP Servers") - .action( - "View Server Extensions", - Box::new(zed_actions::Extensions { - category_filter: Some( - zed_actions::ExtensionCategoryFilter::ContextServers, - ), - }), + let agent_panel_menu = 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| { + Tooltip::for_action_in( + "Toggle Agent Menu", + &ToggleOptionsMenu, + &focus_handle, + window, + cx, ) - .action("Add Custom Server…", Box::new(AddContextServer)) - .separator(); - + } + }, + ) + .anchor(Corner::TopRight) + .with_handle(self.agent_panel_menu_handle.clone()) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |mut menu, _window, _| { if let Some(usage) = usage { menu = menu .header_with_link("Prompt Usage", "Manage", account_url.clone()) @@ -1820,6 +1913,19 @@ impl AgentPanel { .separator() } + menu = menu + .header("MCP Servers") + .action( + "View Server Extensions", + Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), + }), + ) + .action("Add Custom Server…", Box::new(AddContextServer)) + .separator(); + menu = menu .action("Rules…", Box::new(OpenRulesLibrary::default())) .action("Settings", Box::new(OpenConfiguration)) @@ -1861,27 +1967,8 @@ impl AgentPanel { .px(DynamicSpacing::Base08.rems(cx)) .border_l_1() .border_color(cx.theme().colors().border) - .child( - IconButton::new("new", IconName::Plus) - .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) - .tooltip(move |window, cx| { - Tooltip::for_action_in( - "New Thread", - &NewThread::default(), - &focus_handle, - window, - cx, - ) - }) - .on_click(move |_event, window, cx| { - window.dispatch_action( - NewThread::default().boxed_clone(), - cx, - ); - }), - ) - .child(agent_extra_menu), + .child(new_thread_menu) + .child(agent_panel_menu), ), ) } @@ -1893,6 +1980,9 @@ impl AgentPanel { message_editor, .. } => (thread.read(cx), message_editor.read(cx)), + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2031,6 +2121,9 @@ impl AgentPanel { return false; } } + ActiveView::AcpThread { .. } => { + return false; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return false; } @@ -2615,6 +2708,9 @@ impl AgentPanel { ) -> Option { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => thread, + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2961,6 +3057,9 @@ impl AgentPanel { .detach(); }); } + ActiveView::AcpThread { .. } => { + unimplemented!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { TextThreadEditor::insert_dragged_files( @@ -2979,8 +3078,10 @@ impl AgentPanel { fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - if matches!(self.active_view, ActiveView::TextThread { .. }) { - key_context.add("prompt_editor"); + match &self.active_view { + ActiveView::AcpThread { .. } => key_context.add("acp_thread"), + ActiveView::TextThread { .. } => key_context.add("prompt_editor"), + ActiveView::Thread { .. } | ActiveView::History | ActiveView::Configuration => {} } key_context } @@ -3034,6 +3135,7 @@ impl Render for AgentPanel { }); this.continue_conversation(window, cx); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -3075,6 +3177,10 @@ impl Render for AgentPanel { }) .child(h_flex().child(message_editor.clone())) .child(self.render_drag_target(cx)), + ActiveView::AcpThread { thread_view, .. } => parent + .relative() + .child(thread_view.clone()) + .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.history.clone()), ActiveView::TextThread { context_editor, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index b5ab5a147e3077a3465d93c3c38b1b7cae970da5..3170ec4a267d76791968b410e9426079a6ae1f2d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,3 +1,4 @@ +mod acp; mod active_thread; mod agent_configuration; mod agent_diff; @@ -54,42 +55,76 @@ pub use ui::preview::{all_agent_previews, get_agent_preview}; actions!( agent, [ + /// Creates a new text-based conversation thread. NewTextThread, + /// Creates a new external agent conversation thread. + NewAcpThread, + /// Toggles the context picker interface for adding files, symbols, or other context. ToggleContextPicker, + /// Toggles the navigation menu for switching between threads and views. ToggleNavigationMenu, + /// Toggles the options menu for agent settings and preferences. ToggleOptionsMenu, + /// Deletes the recently opened thread from history. DeleteRecentlyOpenThread, + /// Toggles the profile selector for switching between agent profiles. ToggleProfileSelector, + /// Removes all added context from the current conversation. RemoveAllContext, + /// Expands the message editor to full size. ExpandMessageEditor, + /// Opens the conversation history view. OpenHistory, + /// Adds a context server to the configuration. AddContextServer, + /// Removes the currently selected thread. RemoveSelectedThread, - Chat, + /// Starts a chat conversation with follow-up enabled. ChatWithFollow, + /// Cycles to the next inline assist suggestion. CycleNextInlineAssist, + /// Cycles to the previous inline assist suggestion. CyclePreviousInlineAssist, + /// Moves focus up in the interface. FocusUp, + /// Moves focus down in the interface. FocusDown, + /// Moves focus left in the interface. FocusLeft, + /// Moves focus right in the interface. FocusRight, + /// Removes the currently focused context item. RemoveFocusedContext, + /// Accepts the suggested context item. AcceptSuggestedContext, + /// Opens the active thread as a markdown file. OpenActiveThreadAsMarkdown, + /// Opens the agent diff view to review changes. OpenAgentDiff, + /// Keeps the current suggestion or change. Keep, + /// Rejects the current suggestion or change. Reject, + /// Rejects all suggestions or changes. RejectAll, + /// Keeps all suggestions or changes. KeepAll, + /// Follows the agent's suggestions. Follow, + /// Resets the trial upsell notification. ResetTrialUpsell, + /// Resets the trial end upsell notification. ResetTrialEndUpsell, + /// Continues the current thread. ContinueThread, + /// Continues the thread with burn mode enabled. ContinueWithBurnMode, + /// Toggles burn mode for faster responses. ToggleBurnMode, ] ); +/// Creates a new conversation thread, optionally based on an existing thread. #[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] #[serde(deny_unknown_fields)] @@ -98,6 +133,7 @@ pub struct NewThread { from_thread_id: Option, } +/// Opens the profile management interface for configuring agent tools and settings. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] #[serde(deny_unknown_fields)] diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 117dcf4f8e17bc99c4bd6ed75af070d84e5b1015..64498e928130d0debfd8a30bdcbcc010c0de48a1 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -475,6 +475,7 @@ impl CodegenAlternative { stop: Vec::new(), temperature, messages: vec![request_message], + thinking_allowed: false, } })) } diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index f303f34a52856a068f1d2da33cf1f0a4fb5813a5..5cc56b014e140ac1cba91606f3ceddfa9b477dbd 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -1,6 +1,6 @@ mod completion_provider; mod fetch_context_picker; -mod file_context_picker; +pub(crate) mod file_context_picker; mod rules_context_picker; mod symbol_context_picker; mod thread_context_picker; @@ -426,6 +426,7 @@ impl ContextPicker { this.add_recent_file(project_path.clone(), window, cx); }) }, + None, ) } RecentEntry::Thread(thread) => { @@ -443,6 +444,7 @@ impl ContextPicker { .detach_and_log_err(cx); }) }, + None, ) } } diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index ab91ded2c8e45ffd8c840f9dacaa413137dacd51..b377e40b193d090a61b88232098fd45645a2ab4f 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -686,6 +686,7 @@ impl ContextPickerCompletionProvider { let mut label = CodeLabel::plain(symbol.name.clone(), 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); let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path)); let new_text_len = new_text.len(); diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index c9c173a68be5191e77690e826378ca52d3db9684..65b72cbba5f15aa3f527b77939a80abff7a05c05 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -660,7 +660,6 @@ impl InlineAssistant { height: Some(prompt_editor_height), render: build_assist_editor_renderer(prompt_editor), priority: 0, - render_in_minimap: false, }, BlockProperties { style: BlockStyle::Sticky, @@ -675,7 +674,6 @@ impl InlineAssistant { .into_any_element() }), priority: 0, - render_in_minimap: false, }, ]; @@ -1451,7 +1449,6 @@ impl InlineAssistant { .into_any_element() }), priority: 0, - render_in_minimap: false, }); } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 55c0974fc1d2bdbd65e0b6d746abf7f4ef10654d..ff18a95f3f8b84eb0876a099cb664aa0908bed8f 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -18,6 +18,7 @@ use ui::{ListItem, ListItemSpacing, prelude::*}; actions!( agent, [ + /// Toggles the language model selector dropdown. #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] ToggleModelSelector ] diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 70d2b6e06619e36df37aa6280c60d38ccfb4fbaa..d2b136f274f98842ee248016b400883083ab62d5 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::rc::Rc; use std::sync::Arc; +use crate::agent_diff::AgentDiffThread; use crate::agent_model_selector::AgentModelSelector; use crate::language_model_selector::ToggleModelSelector; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; @@ -47,13 +48,14 @@ use ui::{ }; use util::ResultExt as _; use workspace::{CollaboratorId, Workspace}; +use zed_actions::agent::Chat; use zed_llm_client::CompletionIntent; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::profile_selector::ProfileSelector; use crate::{ - ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, + ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector, register_agent_preview, }; @@ -474,9 +476,12 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { - if let Ok(diff) = - AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx) - { + 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)); } @@ -1160,7 +1165,7 @@ impl MessageEditor { }) .child( h_flex() - .id("file-name") + .id(("file-name", index)) .pr_8() .gap_1p5() .max_w_full() @@ -1171,9 +1176,16 @@ impl MessageEditor { .gap_0p5() .children(file_name) .children(file_path), - ), // TODO: Implement line diff - // .child(Label::new("+").color(Color::Created)) - // .child(Label::new("-").color(Color::Deleted)), + ) + .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() @@ -1446,6 +1458,7 @@ impl MessageEditor { tool_choice: None, stop: vec![], temperature: AgentSettings::temperature_for_model(&model.model, cx), + thinking_allowed: true, }; Some(model.model.count_tokens(request, cx)) @@ -1613,6 +1626,7 @@ impl Render for MessageEditor { v_flex() .size_full() + .bg(cx.theme().colors().panel_background) .when(changed_buffers.len() > 0, |parent| { parent.child(self.render_edits_bar(&changed_buffers, window, cx)) }) diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 162b45413f3aeb4295aa7878e34919b4a0c73be9..91867957cdcd1b3cb2ff9c40d385737b74d969f1 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -297,6 +297,7 @@ impl TerminalInlineAssistant { tool_choice: None, stop: Vec::new(), temperature, + thinking_allowed: false, } })) } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index d11deb790820ba18a7437ac50ed3d5b2e8d4c9c0..2941da19653fa6ebbd581663ed675af6b57a2d30 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -38,8 +38,8 @@ use language::{ language_settings::{SoftWrap, all_language_settings}, }; use language_model::{ - ConfigurationError, LanguageModelImage, LanguageModelProviderTosView, LanguageModelRegistry, - Role, + ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView, + LanguageModelRegistry, Role, }; use multi_buffer::MultiBufferRow; use picker::{Picker, popover_menu::PickerPopoverMenu}; @@ -85,16 +85,24 @@ use assistant_context::{ actions!( assistant, [ + /// Sends the current message to the assistant. Assist, + /// Confirms and executes the entered slash command. ConfirmCommand, + /// Copies code from the assistant's response to the clipboard. CopyCode, + /// Cycles between user and assistant message roles. 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, ] ); +/// Inserts files that were dragged and dropped into the assistant conversation. #[derive(PartialEq, Clone, Action)] #[action(namespace = assistant, no_json, no_register)] pub enum InsertDraggedFiles { @@ -1248,7 +1256,6 @@ impl TextThreadEditor { ), priority: usize::MAX, render: render_block(MessageMetadata::from(message)), - render_in_minimap: false, }; let mut new_blocks = vec![]; let mut block_index_to_message = vec![]; @@ -1850,7 +1857,6 @@ impl TextThreadEditor { .into_any_element() }), priority: 0, - render_in_minimap: false, }) }) .collect::>(); @@ -3055,7 +3061,7 @@ fn token_state(context: &Entity, cx: &App) -> Option anyhow::Result { + pub async fn ask_password(&mut self, prompt: String) -> Result { let (tx, rx) = oneshot::channel(); self.tx.send((prompt, tx)).await?; Ok(rx.await?) } } -#[cfg(unix)] pub struct AskPassSession { - script_path: PathBuf, + #[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<()>, askpass_opened_rx: Option>, askpass_kill_master_rx: Option>, } -#[cfg(unix)] +#[cfg(not(target_os = "windows"))] +const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(target_os = "windows")] +const ASKPASS_SCRIPT_NAME: &str = "askpass.ps1"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. #[must_use] - pub async fn new( - executor: &BackgroundExecutor, - mut delegate: AskPassDelegate, - ) -> anyhow::Result { + 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.sh"); + let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>(); - let listener = - UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?; - let zed_path = get_shell_safe_zed_path()?; + 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_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>(); let mut kill_tx = 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); @@ -93,10 +101,14 @@ impl AskPassSession { if let Some(password) = delegate .ask_password(prompt.to_string()) .await - .context("failed to get askpass password") + .context("getting askpass password") .log_err() { stream.write_all(password.as_bytes()).await.log_err(); + #[cfg(target_os = "windows")] + { + *askpass_secret.lock() = password; + } } else { if let Some(kill_tx) = kill_tx.take() { kill_tx.send(()).log_err(); @@ -112,34 +124,49 @@ impl AskPassSession { }); // Create an askpass script that communicates back to this process. - let askpass_script = format!( - "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", - zed_exe = zed_path, - askpass_socket = askpass_socket.display(), - print_args = "printf '%s\\0' \"$@\"", - shebang = "#!/bin/sh", - ); - fs::write(&askpass_script_path, askpass_script).await?; + 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() + ); 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_kill_master_rx: Some(askpass_kill_master_rx), askpass_opened_rx: Some(askpass_opened_rx), }) } - pub fn script_path(&self) -> &Path { + #[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 // drop order this takes an &mut, so you can `drop()` it after you're done with the master process. pub async fn run(&mut self) -> AskPassResult { - let connection_timeout = Duration::from_secs(10); + // This is the default timeout setting used by VSCode. + let connection_timeout = Duration::from_secs(17); let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_kill_master_rx = self .askpass_kill_master_rx @@ -158,14 +185,19 @@ impl AskPassSession { } } } + + /// 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() + } } /// 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. -#[cfg(unix)] pub fn main(socket: &str) { + use net::UnixStream; use std::io::{self, Read, Write}; - use std::os::unix::net::UnixStream; use std::process::exit; let mut stream = match UnixStream::connect(socket) { @@ -182,6 +214,10 @@ pub fn main(socket: &str) { exit(1); } + #[cfg(target_os = "windows")] + while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') { + buffer.pop(); + } if buffer.last() != Some(&b'\0') { buffer.push(b'\0'); } @@ -202,28 +238,28 @@ pub fn main(socket: &str) { exit(1); } } -#[cfg(not(unix))] -pub fn main(_socket: &str) {} -#[cfg(not(unix))] -pub struct AskPassSession { - path: PathBuf, +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_askpass_script(zed_path: &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, + askpass_socket = askpass_socket.display(), + print_args = "printf '%s\\0' \"$@\"", + shebang = "#!/bin/sh", + ) } -#[cfg(not(unix))] -impl AskPassSession { - pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result { - Ok(Self { - path: PathBuf::new(), - }) - } - - pub fn script_path(&self) -> &Path { - &self.path - } - - pub async fn run(&mut self) -> AskPassResult { - futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))).await; - AskPassResult::Timedout - } +#[inline] +#[cfg(target_os = "windows")] +fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String { + format!( + r#" + $ErrorActionPreference = 'Stop'; + ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null + "#, + zed_exe = zed_path.display(), + askpass_socket = askpass_socket.display(), + ) } diff --git a/crates/assistant_context/src/assistant_context.rs b/crates/assistant_context/src/assistant_context.rs index aaaef152503e477c0bff4e8036c6460d6e9fde46..136468e084593ef6b6475d29d8526d683b1bdc7b 100644 --- a/crates/assistant_context/src/assistant_context.rs +++ b/crates/assistant_context/src/assistant_context.rs @@ -2293,6 +2293,7 @@ impl AssistantContext { tool_choice: None, stop: Vec::new(), temperature: model.and_then(|model| AgentSettings::temperature_for_model(model, cx)), + thinking_allowed: true, }; for message in self.messages(cx) { if message.status != MessageStatus::Done { diff --git a/crates/assistant_slash_command/src/extension_slash_command.rs b/crates/assistant_slash_command/src/extension_slash_command.rs index 6cc1f73c476d6d5332abc9e77bd56f54a88eceee..74c46ffb5ffefb2ccbefdba8edec4e9e778489b5 100644 --- a/crates/assistant_slash_command/src/extension_slash_command.rs +++ b/crates/assistant_slash_command/src/extension_slash_command.rs @@ -34,6 +34,11 @@ impl ExtensionSlashCommandProxy for SlashCommandRegistryProxy { self.slash_command_registry .register_command(ExtensionSlashCommand::new(extension, command), false) } + + fn unregister_slash_command(&self, command_name: Arc) { + self.slash_command_registry + .unregister_command_by_name(&command_name) + } } /// An adapter that allows an [`LspAdapterDelegate`] to be used as a [`WorktreeDelegate`]. diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index a8df1131c67e4dcf4716d24be55a16e94e30e7c7..5a54e86eac15c2846e7e72ee45b47ab014cd69e6 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/crates/assistant_tool/Cargo.toml @@ -22,6 +22,7 @@ gpui.workspace = true icons.workspace = true language.workspace = true language_model.workspace = true +log.workspace = true parking_lot.workspace = true project.workspace = true regex.workspace = true diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index 0877f18060d91f5e031477cd2590fad85dc22ecb..e983075cd1e6db22af77856d50a43ebf812de825 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; +use clock; use collections::BTreeMap; use futures::{FutureExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity}; @@ -7,7 +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; +use util::{RangeExt, ResultExt as _}; /// Tracks actions performed by tools in a thread pub struct ActionLog { @@ -17,6 +18,8 @@ pub struct ActionLog { edited_since_project_diagnostics_check: bool, /// The project this action log is associated with project: Entity, + /// Tracks which buffer versions have already been notified as changed externally + notified_versions: BTreeMap, clock::Global>, } impl ActionLog { @@ -26,6 +29,7 @@ impl ActionLog { tracked_buffers: BTreeMap::default(), edited_since_project_diagnostics_check: false, project, + notified_versions: BTreeMap::default(), } } @@ -43,6 +47,10 @@ impl ActionLog { self.edited_since_project_diagnostics_check } + pub fn latest_snapshot(&self, buffer: &Entity) -> Option { + Some(self.tracked_buffers.get(buffer)?.snapshot.clone()) + } + fn track_buffer_internal( &mut self, buffer: Entity, @@ -51,6 +59,7 @@ impl ActionLog { ) -> &mut TrackedBuffer { let status = if is_created { if let Some(tracked) = self.tracked_buffers.remove(&buffer) { + self.notified_versions.remove(&buffer); match tracked.status { TrackedBufferStatus::Created { existing_file_content, @@ -106,7 +115,7 @@ impl ActionLog { TrackedBuffer { buffer: buffer.clone(), diff_base, - unreviewed_edits: unreviewed_edits, + unreviewed_edits, snapshot: text_snapshot.clone(), status, version: buffer.read(cx).version(), @@ -165,6 +174,7 @@ impl ActionLog { // If the buffer had been edited by a tool, but it got // deleted externally, we want to stop tracking it. self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); } cx.notify(); } @@ -178,6 +188,7 @@ impl ActionLog { // resurrected externally, we want to clear the edits we // were tracking and reset the buffer's state. self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); self.track_buffer_internal(buffer, false, cx); } cx.notify(); @@ -483,6 +494,7 @@ impl ActionLog { match tracked_buffer.status { TrackedBufferStatus::Created { .. } => { self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); cx.notify(); } TrackedBufferStatus::Modified => { @@ -508,6 +520,7 @@ impl ActionLog { match tracked_buffer.status { TrackedBufferStatus::Deleted => { self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); cx.notify(); } _ => { @@ -616,6 +629,7 @@ impl ActionLog { }; self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); cx.notify(); task } @@ -629,6 +643,7 @@ impl ActionLog { // Clear all tracked edits for this buffer and start over as if we just read it. self.tracked_buffers.remove(&buffer); + self.notified_versions.remove(&buffer); self.buffer_read(buffer.clone(), cx); cx.notify(); save @@ -704,6 +719,22 @@ impl ActionLog { cx.notify(); } + pub fn reject_all_edits(&mut self, cx: &mut Context) -> Task<()> { + let futures = self.changed_buffers(cx).into_keys().map(|buffer| { + let reject = self.reject_edits_in_ranges(buffer, vec![Anchor::MIN..Anchor::MAX], cx); + + async move { + reject.await.log_err(); + } + }); + + let task = futures::future::join_all(futures); + + cx.spawn(async move |_, _| { + task.await; + }) + } + /// Returns the set of buffers that contain edits that haven't been reviewed by the user. pub fn changed_buffers(&self, cx: &App) -> BTreeMap, Entity> { self.tracked_buffers @@ -713,6 +744,33 @@ impl ActionLog { .collect() } + /// Returns stale buffers that haven't been notified yet + pub fn unnotified_stale_buffers<'a>( + &'a self, + cx: &'a App, + ) -> impl Iterator> { + self.stale_buffers(cx).filter(|buffer| { + let buffer_entity = buffer.read(cx); + self.notified_versions + .get(buffer) + .map_or(true, |notified_version| { + *notified_version != buffer_entity.version + }) + }) + } + + /// Marks the given buffers as notified at their current versions + pub fn mark_buffers_as_notified( + &mut self, + buffers: impl IntoIterator>, + cx: &App, + ) { + for buffer in buffers { + let version = buffer.read(cx).version.clone(); + self.notified_versions.insert(buffer, version); + } + } + /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers diff --git a/crates/assistant_tool/src/tool_schema.rs b/crates/assistant_tool/src/tool_schema.rs index 001b16ac87f02d3783d606ec3bc8d69a0cefd5a0..7b48f93ba6d23bcc1a6e2cf051737efaf69fa595 100644 --- a/crates/assistant_tool/src/tool_schema.rs +++ b/crates/assistant_tool/src/tool_schema.rs @@ -25,10 +25,15 @@ 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 let Some(Value::String(type_str)) = obj.get("type") { - if type_str == "object" && !obj.contains_key("additionalProperties") { + 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)); } + + // OpenAI API requires non-missing `properties` + if !obj.contains_key("properties") { + obj.insert("properties".to_string(), Value::Object(Default::default())); + } } } Ok(()) diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs index c72c52ba7a668ca31c91242872d7ef0c4834fb17..9a6ec49914eea3cd22f014ce2a5c014d1dca1220 100644 --- a/crates/assistant_tool/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -1,18 +1,52 @@ -use std::sync::Arc; - -use collections::{HashMap, IndexMap}; -use gpui::App; +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>, + context_server_tools_by_name: HashMap>, next_tool_id: ToolId, } @@ -24,16 +58,20 @@ impl ToolWorkingSet { .or_else(|| ToolRegistry::global(cx).tool(name)) } - pub fn tools(&self, cx: &App) -> Vec> { - let mut tools = ToolRegistry::global(cx).tools(); - tools.extend(self.context_server_tools_by_id.values().cloned()); + 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) { + for (_, tool) in self.tools(cx) { tools_by_source .entry(tool.source()) .or_insert_with(Vec::new) @@ -49,27 +87,324 @@ impl ToolWorkingSet { tools_by_source } - pub fn insert(&mut self, tool: Arc) -> ToolId { + 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()); - self.tools_changed(); tool_id } - pub fn remove(&mut self, tool_ids_to_remove: &[ToolId]) { - self.context_server_tools_by_id - .retain(|id, _| !tool_ids_to_remove.contains(id)); - self.tools_changed(); + 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 } - fn tools_changed(&mut self) { - self.context_server_tools_by_name.clear(); - self.context_server_tools_by_name.extend( - self.context_server_tools_by_id - .values() - .map(|tool| (tool.name(), tool.clone())), + 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, _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/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 83312a07b625404085694194b92ee7c732a67998..eef792f526fb684e83752241194d293064a9f4f7 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -11,6 +11,7 @@ mod list_directory_tool; mod move_path_tool; mod now_tool; mod open_tool; +mod project_notifications_tool; mod read_file_tool; mod schema; mod templates; @@ -45,6 +46,7 @@ pub use edit_file_tool::{EditFileMode, EditFileToolInput}; pub use find_path_tool::FindPathToolInput; 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; @@ -61,6 +63,7 @@ pub fn init(http_client: Arc, cx: &mut App) { 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); diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index 28d6bef9dd899360cd08e28b876830f81a5bb50a..1922b5677a94e0eff8fef2bc12bdab8a0971f395 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -57,7 +57,7 @@ impl Tool for CopyPathTool { } fn icon(&self) -> IconName { - IconName::Clipboard + IconName::ToolCopy } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index b3e198c1b5e276032846dc8a6c2b67b02c917379..224e8357e5a6de98b088aede62daaa8524f2b6c2 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -46,7 +46,7 @@ impl Tool for CreateDirectoryTool { } fn icon(&self) -> IconName { - IconName::Folder + IconName::ToolFolder } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index e45c1976d1f32642b4091e9fad75385a5b4a7c93..b13f9863c9f7203ceb5e236c8a06903be4b93b68 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -46,7 +46,7 @@ impl Tool for DeletePathTool { } fn icon(&self) -> IconName { - IconName::FileDelete + IconName::ToolDeleteFile } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 3b6d38fc06c0e9f8b95f031cb900ace74c5c6b04..84595a37b7069a194694cb70482928148116d465 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -59,7 +59,7 @@ impl Tool for DiagnosticsTool { } fn icon(&self) -> IconName { - IconName::XCircle + IconName::ToolDiagnostics } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index c2540633f76209343766ccc202d3b8abc614a107..0184dff36c0a4130ce2880f3e7e84acb013aadfd 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -719,6 +719,7 @@ impl EditAgent { tools, stop: Vec::new(), temperature: None, + thinking_allowed: true, }; Ok(self.model.stream_completion_text(request, cx).await?.stream) diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/assistant_tools/src/edit_agent/evals.rs index 8df8f677f20861c2cd5834bdcec6ac3ba414cdb0..c7af7dc64e4507dda9e7140e22652c50501e3e75 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/assistant_tools/src/edit_agent/evals.rs @@ -365,17 +365,23 @@ fn eval_disable_cursor_blinking() { // Model | Pass rate // ============================================ // - // claude-3.7-sonnet | 0.99 (2025-06-14) - // claude-sonnet-4 | 0.85 (2025-06-14) - // gemini-2.5-pro-preview-latest | 0.97 (2025-06-16) - // gemini-2.5-flash-preview-04-17 | - // gpt-4.1 | + // claude-3.7-sonnet | 0.59 (2025-07-14) + // claude-sonnet-4 | 0.81 (2025-07-14) + // 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`"; + let possible_diffs = vec![ + include_str!("evals/fixtures/disable_cursor_blinking/possible-01.diff"), + include_str!("evals/fixtures/disable_cursor_blinking/possible-02.diff"), + include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), + include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), + ]; eval( 100, - 0.95, + 0.51, 0.05, EvalInput::from_conversation( vec![ @@ -433,11 +439,7 @@ fn eval_disable_cursor_blinking() { ), ], Some(input_file_content.into()), - EvalAssertion::judge_diff(indoc! {" - - Calls to BlinkManager in `observe_window_activation` were commented out - - The call to `blink_manager.enable` above the call to show_cursor_names was commented out - - All the edits have valid indentation - "}), + EvalAssertion::assert_diff_any(possible_diffs), ), ); } @@ -1263,6 +1265,7 @@ impl EvalAssertion { content: vec![prompt.into()], cache: false, }], + thinking_allowed: true, ..Default::default() }; let mut response = retry_on_rate_limit(async || { @@ -1599,6 +1602,7 @@ impl EditAgentTest { let conversation = LanguageModelRequest { messages, tools, + thinking_allowed: true, ..Default::default() }; diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs index a070738b600f041cbd6b3cc8ad1e8a6462b1d85a..607daa8ce3a129e0f4bc53a00d1a62f479da3932 100644 --- a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs @@ -9132,7 +9132,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) + self.manipulate_lines(window, cx, |lines| lines.sort()) } pub fn sort_lines_case_insensitive( @@ -9141,7 +9141,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_immutable_lines(window, cx, |lines| { + self.manipulate_lines(window, cx, |lines| { lines.sort_by_key(|line| line.to_lowercase()) }) } @@ -9152,7 +9152,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_immutable_lines(window, cx, |lines| { + self.manipulate_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(line.to_lowercase())); }) @@ -9164,7 +9164,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_immutable_lines(window, cx, |lines| { + self.manipulate_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(*line)); }) @@ -9606,20 +9606,20 @@ impl Editor { } pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { - self.manipulate_immutable_lines(window, cx, |lines| lines.reverse()) + self.manipulate_lines(window, cx, |lines| lines.reverse()) } 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_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) } - fn manipulate_lines( + fn manipulate_lines( &mut self, window: &mut Window, cx: &mut Context, - mut manipulate: M, + mut callback: Fn, ) where - M: FnMut(&str) -> LineManipulationResult, + Fn: FnMut(&mut Vec<&str>), { self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); @@ -9652,14 +9652,18 @@ impl Editor { .text_for_range(start_point..end_point) .collect::(); - let LineManipulationResult { new_text, line_count_before, line_count_after} = manipulate(&text); + let mut lines = text.split('\n').collect_vec(); - edits.push((start_point..end_point, new_text)); + let lines_before = lines.len(); + callback(&mut lines); + let lines_after = lines.len(); + + edits.push((start_point..end_point, lines.join("\n"))); // Selections must change based on added and removed line count let start_row = MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32); - let end_row = MultiBufferRow(start_row.0 + line_count_after.saturating_sub(1) as u32); + let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32); new_selections.push(Selection { id: selection.id, start: start_row, @@ -9668,10 +9672,10 @@ impl Editor { reversed: selection.reversed, }); - if line_count_after > line_count_before { - added_lines += line_count_after - line_count_before; - } else if line_count_before > line_count_after { - removed_lines += line_count_before - line_count_after; + if lines_after > lines_before { + added_lines += lines_after - lines_before; + } else if lines_before > lines_after { + removed_lines += lines_before - lines_after; } } @@ -9716,171 +9720,6 @@ impl Editor { }) } - fn manipulate_immutable_lines( - &mut self, - window: &mut Window, - cx: &mut Context, - mut callback: Fn, - ) where - Fn: FnMut(&mut Vec<&str>), - { - self.manipulate_lines(window, cx, |text| { - let mut lines: Vec<&str> = text.split('\n').collect(); - let line_count_before = lines.len(); - - callback(&mut lines); - - LineManipulationResult { - new_text: lines.join("\n"), - line_count_before, - line_count_after: lines.len(), - } - }); - } - - fn manipulate_mutable_lines( - &mut self, - window: &mut Window, - cx: &mut Context, - mut callback: Fn, - ) where - Fn: FnMut(&mut Vec>), - { - self.manipulate_lines(window, cx, |text| { - let mut lines: Vec> = text.split('\n').map(Cow::from).collect(); - let line_count_before = lines.len(); - - callback(&mut lines); - - LineManipulationResult { - new_text: lines.join("\n"), - line_count_before, - line_count_after: lines.len(), - } - }); - } - - pub fn convert_indentation_to_spaces( - &mut self, - _: &ConvertIndentationToSpaces, - window: &mut Window, - cx: &mut Context, - ) { - let settings = self.buffer.read(cx).language_settings(cx); - let tab_size = settings.tab_size.get() as usize; - - self.manipulate_mutable_lines(window, cx, |lines| { - // Allocates a reasonably sized scratch buffer once for the whole loop - let mut reindented_line = String::with_capacity(MAX_LINE_LEN); - // Avoids recomputing spaces that could be inserted many times - let space_cache: Vec> = (1..=tab_size) - .map(|n| IndentSize::spaces(n as u32).chars().collect()) - .collect(); - - for line in lines.iter_mut().filter(|line| !line.is_empty()) { - let mut chars = line.as_ref().chars(); - let mut col = 0; - let mut changed = false; - - while let Some(ch) = chars.next() { - match ch { - ' ' => { - reindented_line.push(' '); - col += 1; - } - '\t' => { - // \t are converted to spaces depending on the current column - let spaces_len = tab_size - (col % tab_size); - reindented_line.extend(&space_cache[spaces_len - 1]); - col += spaces_len; - changed = true; - } - _ => { - // If we dont append before break, the character is consumed - reindented_line.push(ch); - break; - } - } - } - - if !changed { - reindented_line.clear(); - continue; - } - // Append the rest of the line and replace old reference with new one - reindented_line.extend(chars); - *line = Cow::Owned(reindented_line.clone()); - reindented_line.clear(); - } - }); - } - - pub fn convert_indentation_to_tabs( - &mut self, - _: &ConvertIndentationToTabs, - window: &mut Window, - cx: &mut Context, - ) { - let settings = self.buffer.read(cx).language_settings(cx); - let tab_size = settings.tab_size.get() as usize; - - self.manipulate_mutable_lines(window, cx, |lines| { - // Allocates a reasonably sized buffer once for the whole loop - let mut reindented_line = String::with_capacity(MAX_LINE_LEN); - // Avoids recomputing spaces that could be inserted many times - let space_cache: Vec> = (1..=tab_size) - .map(|n| IndentSize::spaces(n as u32).chars().collect()) - .collect(); - - for line in lines.iter_mut().filter(|line| !line.is_empty()) { - let mut chars = line.chars(); - let mut spaces_count = 0; - let mut first_non_indent_char = None; - let mut changed = false; - - while let Some(ch) = chars.next() { - match ch { - ' ' => { - // Keep track of spaces. Append \t when we reach tab_size - spaces_count += 1; - changed = true; - if spaces_count == tab_size { - reindented_line.push('\t'); - spaces_count = 0; - } - } - '\t' => { - reindented_line.push('\t'); - spaces_count = 0; - } - _ => { - // Dont append it yet, we might have remaining spaces - first_non_indent_char = Some(ch); - break; - } - } - } - - if !changed { - reindented_line.clear(); - continue; - } - // Remaining spaces that didn't make a full tab stop - if spaces_count > 0 { - reindented_line.extend(&space_cache[spaces_count - 1]); - } - // If we consume an extra character that was not indentation, add it back - if let Some(extra_char) = first_non_indent_char { - reindented_line.push(extra_char); - } - // Append the rest of the line and replace old reference with new one - reindented_line.extend(chars); - *line = Cow::Owned(reindented_line.clone()); - reindented_line.clear(); - } - }); - } - pub fn convert_to_upper_case( &mut self, _: &ConvertToUpperCase, @@ -21318,13 +21157,6 @@ pub struct LineHighlight { pub type_id: Option, } -struct LineManipulationResult { - pub new_text: String, - pub line_count_before: usize, - pub line_count_after: usize, -} - - fn render_diff_hunk_controls( row: u32, status: &DiffHunkStatus, diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff new file mode 100644 index 0000000000000000000000000000000000000000..1a38a1967f94c974de491c712babb7882020d697 --- /dev/null +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff @@ -0,0 +1,28 @@ +--- before.rs 2025-07-07 11:37:48.434629001 +0300 ++++ expected.rs 2025-07-14 10:33:53.346906775 +0300 +@@ -1780,11 +1780,11 @@ + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff new file mode 100644 index 0000000000000000000000000000000000000000..b484cce48f71b232ddaa947a73940b8bf11846c6 --- /dev/null +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff @@ -0,0 +1,29 @@ +@@ -1778,13 +1778,13 @@ + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { +- let active = window.is_window_active(); ++ // let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff new file mode 100644 index 0000000000000000000000000000000000000000..431e34e48a250bff80efbd5a2cc20ecc25be1020 --- /dev/null +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff @@ -0,0 +1,34 @@ +@@ -1774,17 +1774,17 @@ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&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(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { +- let active = window.is_window_active(); ++ // let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff new file mode 100644 index 0000000000000000000000000000000000000000..64a6b85dd3751407db65da74656b66ee1beaf58b --- /dev/null +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff @@ -0,0 +1,33 @@ +@@ -1774,17 +1774,17 @@ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&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(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 8c7728b4b72c9aa52c717e58fbdd63591dd88f0f..0423f56145bc484108ff958419353e2378a3779a 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -4,6 +4,7 @@ use crate::{ schema::json_schema_for, ui::{COLLAPSED_LINES, ToolOutputPreview}, }; +use agent_settings; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, @@ -14,7 +15,7 @@ use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey}; use futures::StreamExt; use gpui::{ Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task, - TextStyleRefinement, WeakEntity, pulsating_between, px, + TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px, }; use indoc::formatdoc; use language::{ @@ -138,7 +139,7 @@ impl Tool for EditFileTool { } fn icon(&self) -> IconName { - IconName::Pencil + IconName::ToolPencil } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -515,7 +516,9 @@ pub struct EditFileToolCard { 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 { @@ -556,7 +559,7 @@ impl EditFileToolCard { diff_task: None, preview_expanded: true, error_expanded: None, - full_height_expanded: true, + full_height_expanded: expand_edit_card, total_lines: None, } } @@ -755,6 +758,13 @@ impl ToolCard for EditFileToolCard { _ => 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() @@ -773,8 +783,8 @@ impl ToolCard for EditFileToolCard { .child( h_flex() .child( - Icon::new(IconName::Pencil) - .size(IconSize::XSmall) + Icon::new(IconName::ToolPencil) + .size(IconSize::Small) .color(Color::Muted), ) .child( @@ -863,6 +873,18 @@ impl ToolCard for EditFileToolCard { 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() diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 82b15b7a86905219167d4f4fb630e6c9bab2c79d..54d49359baeae51ab4c575824bd5b42728925a66 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -69,10 +69,9 @@ impl FetchTool { .to_str() .context("invalid Content-Type header")?; let content_type = match content_type { - "text/html" => ContentType::Html, - "text/plain" => ContentType::Plaintext, + "text/html" | "application/xhtml+xml" => ContentType::Html, "application/json" => ContentType::Json, - _ => ContentType::Html, + _ => ContentType::Plaintext, }; match content_type { @@ -130,7 +129,7 @@ impl Tool for FetchTool { } fn icon(&self) -> IconName { - IconName::Globe + IconName::ToolWeb } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index 86e67a8f58cd71aedd163e15cb95aeb9e3357a87..fd0e44e42cbe6fad373de21be6e263620c07d3d6 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -68,7 +68,7 @@ impl Tool for FindPathTool { } fn icon(&self) -> IconName { - IconName::SearchCode + IconName::ToolSearch } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -313,7 +313,7 @@ impl ToolCard for FindPathToolCard { .mb_2() .gap_1() .child( - ToolCallCardHeader::new(IconName::SearchCode, matches_label) + ToolCallCardHeader::new(IconName::ToolSearch, matches_label) .with_code_path(&self.glob) .disclosure_slot( Disclosure::new("path-search-disclosure", self.expanded) diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index eb4c8d38e5a586ca7d236906ab537754deb36f1f..053273d71bc01191c19fa1e498290d77e8caac7c 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -70,7 +70,7 @@ impl Tool for GrepTool { } fn icon(&self) -> IconName { - IconName::Regex + IconName::ToolRegex } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index aef186b9ae5adcc0e7d1625d483b1e4d6d9d51ca..723416e2ce1048d42ceca2af18667817a467d1f2 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -58,7 +58,7 @@ impl Tool for ListDirectoryTool { } fn icon(&self) -> IconName { - IconName::Folder + IconName::ToolFolder } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..168ec61ae98529e1c82dcbe1d4334436457bab44 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -0,0 +1,224 @@ +use crate::schema::json_schema_for; +use anyhow::Result; +use assistant_tool::{ActionLog, 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 as _; +use std::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, _: &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 mut stale_files = String::new(); + let mut notified_buffers = Vec::new(); + + for stale_file in action_log.read(cx).unnotified_stale_buffers(cx) { + if let Some(file) = stale_file.read(cx).file() { + writeln!(&mut stale_files, "- {}", file.path().display()).ok(); + notified_buffers.push(stale_file.clone()); + } + } + + if !notified_buffers.is_empty() { + action_log.update(cx, |log, cx| { + log.mark_buffers_as_notified(notified_buffers, cx); + }); + } + + let response = if stale_files.is_empty() { + "No new notifications".to_string() + } else { + // NOTE: Changes to this prompt require a symmetric update in the LLM Worker + const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); + format!("{HEADER}{stale_files}").replace("\r\n", "\n") + }; + + Task::ready(Ok(response.into())).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assistant_tool::ToolResultContent; + use gpui::{AppContext, TestAppContext}; + 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); + }); + + // Run the tool before any changes + let tool = Arc::new(ProjectNotificationsTool); + let provider = Arc::new(FakeLanguageModelProvider); + 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, + ) + }); + + 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); + }); + + // 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, + ) + }); + + // 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"), + }; + + let expected_content = "[The following is an auto-generated notification; do not reply]\n\nThese files have changed since the last read:\n- code.rs\n"; + assert_eq!( + response_text.as_str(), + expected_content, + "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, + ) + }); + + 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" + ); + } + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..24ff678f5e7fd728b94ad4ebce06f2a1dcc6a658 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/description.md @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt @@ -0,0 +1,3 @@ +[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 index 4d40fc6a7c71fc41cb23f689f3e9dc6b699f81c1..6bbc2fc0897fa92d676ab92392dbadac0447be33 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -18,7 +18,6 @@ use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; use ui::IconName; -use util::markdown::MarkdownInlineCode; /// If the model requests to read a file whose size exceeds this, then #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -68,7 +67,7 @@ impl Tool for ReadFileTool { } fn icon(&self) -> IconName { - IconName::FileSearch + IconName::ToolRead } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -78,11 +77,21 @@ impl Tool for ReadFileTool { fn ui_text(&self, input: &serde_json::Value) -> String { match serde_json::from_value::(input.clone()) { Ok(input) => { - let path = MarkdownInlineCode(&input.path); + let path = &input.path; match (input.start_line, input.end_line) { - (Some(start), None) => format!("Read file {path} (from line {start})"), - (Some(start), Some(end)) => format!("Read file {path} (lines {start}-{end})"), - _ => format!("Read file {path}"), + (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(), diff --git a/crates/assistant_tools/src/schema.rs b/crates/assistant_tools/src/schema.rs index 888e11de4e83df853d5d1c252d30cecf84c701a2..10a8bf0acd99131d2c0a80411072f312c9a42f50 100644 --- a/crates/assistant_tools/src/schema.rs +++ b/crates/assistant_tools/src/schema.rs @@ -25,9 +25,7 @@ fn schema_to_json( fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), - // TODO: Gemini docs mention using a subset of OpenAPI 3, so this may benefit from using - // `SchemaSettings::openapi3()`. - LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::draft07() + LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() .with(|settings| { settings.meta_schema = None; settings.inline_subschemas = true; diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 2c582a531069eb9a81340af7eb07731e8df8a96e..03e76f6a5b657a706c2337087984757b62d0ab84 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -2,12 +2,13 @@ use crate::{ schema::json_schema_for, ui::{COLLAPSED_LINES, ToolOutputPreview}, }; +use agent_settings; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; use futures::{FutureExt as _, future::Shared}; use gpui::{ - AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement, - WeakEntity, Window, + Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, + TextStyleRefinement, Transformation, WeakEntity, Window, percentage, }; use language::LineEnding; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; @@ -89,7 +90,7 @@ impl Tool for TerminalTool { } fn icon(&self) -> IconName { - IconName::Terminal + IconName::ToolTerminal } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { @@ -218,7 +219,7 @@ impl Tool for TerminalTool { .update(cx, |project, cx| { project.create_terminal( TerminalKind::Task(task::SpawnInTerminal { - command: program, + command: Some(program), args, cwd, env, @@ -247,6 +248,7 @@ impl Tool for TerminalTool { command_markdown.clone(), working_dir.clone(), cx.entity_id(), + cx, ) }); @@ -441,7 +443,10 @@ impl TerminalToolCard { 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, @@ -453,7 +458,7 @@ impl TerminalToolCard { finished_with_empty_output: false, original_content_len: 0, content_line_count: 0, - preview_expanded: true, + preview_expanded: expand_terminal_card, start_instant: Instant::now(), elapsed_time: None, } @@ -518,6 +523,46 @@ impl ToolCard for TerminalToolCard { .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 \ @@ -555,34 +600,6 @@ impl ToolCard for TerminalToolCard { .size(LabelSize::Small), ) }) - .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.finished_with_empty_output, |header| { header.child( Disclosure::new( @@ -634,6 +651,7 @@ impl ToolCard for TerminalToolCard { 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() diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 4641b7359e1039cefb80e2a4f97ec5db94bfd90e..422204f97d46a487032534a846fce455c5bdc0b3 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -37,7 +37,7 @@ impl Tool for ThinkingTool { } fn icon(&self) -> IconName { - IconName::LightBulb + IconName::ToolBulb } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { diff --git a/crates/assistant_tools/src/ui/tool_call_card_header.rs b/crates/assistant_tools/src/ui/tool_call_card_header.rs index a19ea8f2b7a8ac4a3281a8abb81311937fcf0449..b71453373feb84d91168576a5bc7c22f8d883aa9 100644 --- a/crates/assistant_tools/src/ui/tool_call_card_header.rs +++ b/crates/assistant_tools/src/ui/tool_call_card_header.rs @@ -82,7 +82,7 @@ impl RenderOnce for ToolCallCardHeader { .child( h_flex().h(line_height).justify_center().child( Icon::new(self.icon) - .size(IconSize::XSmall) + .size(IconSize::Small) .color(Color::Muted), ), ) diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 9430ac9d9e245d4f8871fcf120cba9ed48a5ba97..24bc8e9cba36d09a301a5a398e268ff530bdd072 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -143,6 +143,8 @@ impl ToolCard for WebSearchToolCard { _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 { @@ -150,13 +152,12 @@ impl ToolCard for WebSearchToolCard { } else { format!("{} results", response.results.len()).into() }; - ToolCallCardHeader::new(IconName::Globe, "Searched the Web") - .with_secondary_text(text) + ToolCallCardHeader::new(icon, "Searched the Web").with_secondary_text(text) } Some(Err(error)) => { - ToolCallCardHeader::new(IconName::Globe, "Web Search").with_error(error.to_string()) + ToolCallCardHeader::new(icon, "Web Search").with_error(error.to_string()) } - None => ToolCallCardHeader::new(IconName::Globe, "Searching the Web").loading(), + None => ToolCallCardHeader::new(icon, "Searching the Web").loading(), }; let content = self.response.as_ref().and_then(|response| match response { diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 26eb36118a1a946afca0a2f334371b423479ae45..d62a9cdbe330964759fa5362689349c28cd2b713 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -28,7 +28,17 @@ use workspace::Workspace; const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60); -actions!(auto_update, [Check, DismissErrorMessage, ViewReleaseNotes,]); +actions!( + auto_update, + [ + /// Checks for available updates. + Check, + /// Dismisses the update error message. + DismissErrorMessage, + /// Opens the release notes for the current version in a browser. + ViewReleaseNotes, + ] +); #[derive(Serialize)] struct UpdateRequestBody { @@ -628,7 +638,7 @@ impl AutoUpdater { let filename = match OS { "macos" => anyhow::Ok("Zed.dmg"), "linux" => Ok("zed.tar.gz"), - "windows" => Ok("ZedUpdateInstaller.exe"), + "windows" => Ok("zed_editor_installer.exe"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; diff --git a/crates/auto_update_helper/app-icon.ico b/crates/auto_update_helper/app-icon.ico index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..321e90fcfa15d8f84c2619b4d12af892ea5cda66 100644 Binary files a/crates/auto_update_helper/app-icon.ico and b/crates/auto_update_helper/app-icon.ico differ diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index afb135bc974f56d04db93e2a902fe48a64ab8ea7..63baef1f7d178045a2a2b5c976ede9ad75adb646 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -12,7 +12,13 @@ use workspace::Workspace; use workspace::notifications::simple_message_notification::MessageNotification; use workspace::notifications::{NotificationId, show_app_notification}; -actions!(auto_update, [ViewReleaseNotesLocally]); +actions!( + auto_update, + [ + /// Opens the release notes for the current version in a new tab. + ViewReleaseNotesLocally + ] +); pub fn init(cx: &mut App) { notify_if_app_was_updated(cx); diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 75c7a6638ab1443fd192751cb4b02cef3d9c1841..30e2943af3fcb9e8d5141568b2602a8db9a69a6c 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -29,7 +29,7 @@ client.workspace = true collections.workspace = true fs.workspace = true futures.workspace = true -gpui.workspace = true +gpui = { workspace = true, features = ["screen-capture"] } language.workspace = true log.workspace = true postage.workspace = true diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 31ca144cf8a61946318dc518e7ffee29b4c06d6f..7aac72ed46e777a1c70a194cf79f9bad160d1028 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -1389,10 +1389,17 @@ impl Room { let sources = cx.screen_capture_sources(); cx.spawn(async move |this, cx| { - let sources = sources.await??; - let source = sources.first().context("no display found")?; - - let publication = participant.publish_screenshare_track(&**source, cx).await; + let sources = sources + .await + .map_err(|error| error.into()) + .and_then(|sources| sources); + let source = + sources.and_then(|sources| sources.into_iter().next().context("no display found")); + + let publication = match source { + Ok(source) => participant.publish_screenshare_track(&*source, cx).await, + Err(error) => Err(error), + }; this.update(cx, |this, cx| { let live_kit = this diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a97985e69293b3b37f0eceab6183c869102975cc..287c62b753f1ce875ca38a9f2caa62b906e6ee27 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -130,6 +130,13 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result { } fn main() -> Result<()> { + #[cfg(all(not(debug_assertions), target_os = "windows"))] + unsafe { + use ::windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; + + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + } + #[cfg(unix)] util::prevent_root_execution(); @@ -308,19 +315,19 @@ fn main() -> Result<()> { }); let stdin_pipe_handle: Option>> = - stdin_tmp_file.map(|tmp_file| { + stdin_tmp_file.map(|mut tmp_file| { thread::spawn(move || { - let stdin = std::io::stdin().lock(); - if io::IsTerminal::is_terminal(&stdin) { - return Ok(()); + let mut stdin = std::io::stdin().lock(); + if !io::IsTerminal::is_terminal(&stdin) { + io::copy(&mut stdin, &mut tmp_file)?; } - return pipe_to_tmp(stdin, tmp_file); + Ok(()) }) }); - let anonymous_fd_pipe_handles: Vec>> = anonymous_fd_tmp_files + let anonymous_fd_pipe_handles: Vec<_> = anonymous_fd_tmp_files .into_iter() - .map(|(file, tmp_file)| thread::spawn(move || pipe_to_tmp(file, tmp_file))) + .map(|(mut file, mut tmp_file)| thread::spawn(move || io::copy(&mut file, &mut tmp_file))) .collect(); if args.foreground { @@ -342,22 +349,6 @@ fn main() -> Result<()> { Ok(()) } -fn pipe_to_tmp(mut src: impl io::Read, mut dest: fs::File) -> Result<()> { - let mut buffer = [0; 8 * 1024]; - loop { - let bytes_read = match src.read(&mut buffer) { - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - res => res?, - }; - if bytes_read == 0 { - break; - } - io::Write::write_all(&mut dest, &buffer[..bytes_read])?; - } - io::Write::flush(&mut dest)?; - Ok(()) -} - fn anonymous_fd(path: &str) -> Option { #[cfg(target_os = "linux")] { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 86612bd15b12750f22fdedbb3475c7df6e6cfc99..c4211f72c819cfed5c0ee2f555356aa970968bc5 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -81,7 +81,17 @@ pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(500); pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(10); pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(20); -actions!(client, [SignIn, SignOut, Reconnect]); +actions!( + client, + [ + /// Signs in to Zed account. + SignIn, + /// Signs out of Zed account. + SignOut, + /// Reconnects to the collaboration server. + Reconnect + ] +); #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct ClientSettingsContent { diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 74eff5ec4e6016b4918c4881c79c7b2dc1687411..7b536a2d24bd408d7fa49e80453ec463c95e5347 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -35,6 +35,7 @@ dashmap.workspace = true derive_more.workspace = true envy = "0.4.2" futures.workspace = true +gpui = { workspace = true, features = ["screen-capture"] } hex.workspace = true http_client.workspace = true jsonwebtoken.workspace = true @@ -93,6 +94,7 @@ context_server.workspace = true ctor.workspace = true dap = { workspace = true, features = ["test-support"] } dap_adapters = { workspace = true, features = ["test-support"] } +dap-types.workspace = true debugger_ui = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } extension.workspace = true diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 63c8ab8ef4cdff60cd1f1e6c05d878f8cd4d6fb4..ca840493ad5772b2eaa054f86c8c927fea5d13b9 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -26,7 +26,7 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id" CREATE TABLE "access_tokens" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "user_id" INTEGER REFERENCES users (id), + "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "impersonated_user_id" INTEGER REFERENCES users (id), "hash" VARCHAR(128) ); @@ -107,7 +107,7 @@ CREATE INDEX "index_worktree_entries_on_project_id" ON "worktree_entries" ("proj CREATE INDEX "index_worktree_entries_on_project_id_and_worktree_id" ON "worktree_entries" ("project_id", "worktree_id"); CREATE TABLE "project_repositories" ( - "project_id" INTEGER NOT NULL, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, "abs_path" VARCHAR, "id" INTEGER NOT NULL, "entry_ids" VARCHAR, @@ -124,7 +124,7 @@ CREATE TABLE "project_repositories" ( CREATE INDEX "index_project_repositories_on_project_id" ON "project_repositories" ("project_id"); CREATE TABLE "project_repository_statuses" ( - "project_id" INTEGER NOT NULL, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, "repository_id" INTEGER NOT NULL, "repo_path" VARCHAR NOT NULL, "status" INT8 NOT NULL, diff --git a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql b/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql new file mode 100644 index 0000000000000000000000000000000000000000..6d898c481199f4770ab7df5ce66c08e2fdf42423 --- /dev/null +++ b/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql @@ -0,0 +1,25 @@ +DELETE FROM project_repositories +WHERE project_id NOT IN (SELECT id FROM projects); + +ALTER TABLE project_repositories + ADD CONSTRAINT fk_project_repositories_project_id + FOREIGN KEY (project_id) + REFERENCES projects (id) + ON DELETE CASCADE + NOT VALID; + +ALTER TABLE project_repositories + VALIDATE CONSTRAINT fk_project_repositories_project_id; + +DELETE FROM project_repository_statuses +WHERE project_id NOT IN (SELECT id FROM projects); + +ALTER TABLE project_repository_statuses + ADD CONSTRAINT fk_project_repository_statuses_project_id + FOREIGN KEY (project_id) + REFERENCES projects (id) + ON DELETE CASCADE + NOT VALID; + +ALTER TABLE project_repository_statuses + VALIDATE CONSTRAINT fk_project_repository_statuses_project_id; diff --git a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql new file mode 100644 index 0000000000000000000000000000000000000000..ae0ffe24f6322196358225ff4159df9d1cfa6298 --- /dev/null +++ b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql @@ -0,0 +1,3 @@ +ALTER TABLE access_tokens DROP CONSTRAINT access_tokens_user_id_fkey; +ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/crates/collab/seed.default.json b/crates/collab/seed.default.json index dee924e103d620f354a2dac6db32762ee9d86c9c..983594d6235113011c7571684ad3d8eac649aeff 100644 --- a/crates/collab/seed.default.json +++ b/crates/collab/seed.default.json @@ -1,12 +1,33 @@ { "admins": [ "nathansobo", - "as-cii", "maxbrunsfeld", - "iamnbutler", - "mikayla-maki", + "as-cii", "JosephTLyons", - "rgbkrk" + "maxdeviant", + "SomeoneToIgnore", + "mikayla-maki", + "agu-z", + "osiewicz", + "ConradIrwin", + "benbrandt", + "bennetbo", + "smitbarmase", + "notpeter", + "rgbkrk", + "JunkuiZhang", + "Anthony-Eid", + "rtfeldman", + "danilo-leal", + "MrSubidubi", + "cole-miller", + "osyvokon", + "probably-neb", + "mgsloan", + "P1n3appl3", + "mslzed", + "franciskafyi", + "katie-z-geer" ], "channels": ["zed"] } diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index c8df066cbf1bbefd0515000a093d34371842c387..00688a1e82be056a06e08a84013d4e95474bc971 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -5,7 +5,7 @@ use axum::{ routing::{get, post}, }; use chrono::{DateTime, SecondsFormat, Utc}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; @@ -21,12 +21,13 @@ use stripe::{ PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus, }; use util::{ResultExt, maybe}; +use zed_llm_client::LanguageModelProvider; use crate::api::events::SnowflakeRow; use crate::db::billing_subscription::{ StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind, }; -use crate::llm::db::subscription_usage_meter::CompletionMode; +use crate::llm::db::subscription_usage_meter::{self, CompletionMode}; use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND}; use crate::rpc::{ResultExt as _, Server}; use crate::stripe_client::{ @@ -1416,18 +1417,21 @@ async fn sync_model_request_usage_with_stripe( let usage_meters = llm_db .get_current_subscription_usage_meters(Utc::now()) .await?; - let usage_meters = usage_meters - .into_iter() - .filter(|(_, usage)| !staff_user_ids.contains(&usage.user_id)) - .collect::>(); - let user_ids = usage_meters - .iter() - .map(|(_, usage)| usage.user_id) - .collect::>(); - let billing_subscriptions = app - .db - .get_active_zed_pro_billing_subscriptions(user_ids) - .await?; + let mut usage_meters_by_user_id = + HashMap::>::default(); + for (usage_meter, usage) in usage_meters { + let meters = usage_meters_by_user_id.entry(usage.user_id).or_default(); + meters.push(usage_meter); + } + + log::info!("Stripe usage sync: Retrieving Zed Pro subscriptions"); + let get_zed_pro_subscriptions_started_at = Utc::now(); + let billing_subscriptions = app.db.get_active_zed_pro_billing_subscriptions().await?; + log::info!( + "Stripe usage sync: Retrieved {} Zed Pro subscriptions in {}", + billing_subscriptions.len(), + Utc::now() - get_zed_pro_subscriptions_started_at + ); let claude_sonnet_4 = stripe_billing .find_price_by_lookup_key("claude-sonnet-4-requests") @@ -1451,59 +1455,90 @@ async fn sync_model_request_usage_with_stripe( .find_price_by_lookup_key("claude-3-7-sonnet-requests-max") .await?; - let usage_meter_count = usage_meters.len(); + let model_mode_combinations = [ + ("claude-opus-4", CompletionMode::Max), + ("claude-opus-4", CompletionMode::Normal), + ("claude-sonnet-4", CompletionMode::Max), + ("claude-sonnet-4", CompletionMode::Normal), + ("claude-3-7-sonnet", CompletionMode::Max), + ("claude-3-7-sonnet", CompletionMode::Normal), + ("claude-3-5-sonnet", CompletionMode::Normal), + ]; - log::info!("Stripe usage sync: Syncing {usage_meter_count} usage meters"); + let billing_subscription_count = billing_subscriptions.len(); - for (usage_meter, usage) in usage_meters { + log::info!("Stripe usage sync: Syncing {billing_subscription_count} Zed Pro subscriptions"); + + for (user_id, (billing_customer, billing_subscription)) in billing_subscriptions { maybe!(async { - let Some((billing_customer, billing_subscription)) = - billing_subscriptions.get(&usage.user_id) - else { - bail!( - "Attempted to sync usage meter for user who is not a Stripe customer: {}", - usage.user_id - ); - }; + if staff_user_ids.contains(&user_id) { + return anyhow::Ok(()); + } let stripe_customer_id = StripeCustomerId(billing_customer.stripe_customer_id.clone().into()); let stripe_subscription_id = StripeSubscriptionId(billing_subscription.stripe_subscription_id.clone().into()); - let model = llm_db.model_by_id(usage_meter.model_id)?; + let usage_meters = usage_meters_by_user_id.get(&user_id); - let (price, meter_event_name) = match model.name.as_str() { - "claude-opus-4" => match usage_meter.mode { - CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"), - CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"), - }, - "claude-sonnet-4" => match usage_meter.mode { - CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"), - CompletionMode::Max => (&claude_sonnet_4_max, "claude_sonnet_4/requests/max"), - }, - "claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"), - "claude-3-7-sonnet" => match usage_meter.mode { - CompletionMode::Normal => (&claude_3_7_sonnet, "claude_3_7_sonnet/requests"), - CompletionMode::Max => { - (&claude_3_7_sonnet_max, "claude_3_7_sonnet/requests/max") + for (model, mode) in &model_mode_combinations { + let Ok(model) = + llm_db.model(LanguageModelProvider::Anthropic, model) + else { + log::warn!("Failed to load model for user {user_id}: {model}"); + continue; + }; + + let (price, meter_event_name) = match model.name.as_str() { + "claude-opus-4" => match mode { + CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"), + CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"), + }, + "claude-sonnet-4" => match mode { + CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"), + CompletionMode::Max => { + (&claude_sonnet_4_max, "claude_sonnet_4/requests/max") + } + }, + "claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"), + "claude-3-7-sonnet" => match mode { + CompletionMode::Normal => { + (&claude_3_7_sonnet, "claude_3_7_sonnet/requests") + } + CompletionMode::Max => { + (&claude_3_7_sonnet_max, "claude_3_7_sonnet/requests/max") + } + }, + model_name => { + bail!("Attempted to sync usage meter for unsupported model: {model_name:?}") } - }, - model_name => { - bail!("Attempted to sync usage meter for unsupported model: {model_name:?}") + }; + + let model_requests = usage_meters + .and_then(|usage_meters| { + usage_meters + .iter() + .find(|meter| meter.model_id == model.id && meter.mode == *mode) + }) + .map(|usage_meter| usage_meter.requests) + .unwrap_or(0); + + if model_requests > 0 { + stripe_billing + .subscribe_to_price(&stripe_subscription_id, price) + .await?; } - }; - stripe_billing - .subscribe_to_price(&stripe_subscription_id, price) - .await?; - stripe_billing - .bill_model_request_usage( - &stripe_customer_id, - meter_event_name, - usage_meter.requests, - ) - .await?; + stripe_billing + .bill_model_request_usage(&stripe_customer_id, meter_event_name, model_requests) + .await + .with_context(|| { + format!( + "Failed to bill model request usage of {model_requests} for {stripe_customer_id}: {meter_event_name}", + ) + })?; + } Ok(()) }) @@ -1512,7 +1547,7 @@ async fn sync_model_request_usage_with_stripe( } log::info!( - "Stripe usage sync: Synced {usage_meter_count} usage meters in {:?}", + "Stripe usage sync: Synced {billing_subscription_count} Zed Pro subscriptions in {}", Utc::now() - started_at ); diff --git a/crates/collab/src/db/queries/billing_subscriptions.rs b/crates/collab/src/db/queries/billing_subscriptions.rs index f25d0abeaaba9b303d915350d138557e268824f9..9f82e3dbc4938d2c9a60f9c16ed69c485c3997be 100644 --- a/crates/collab/src/db/queries/billing_subscriptions.rs +++ b/crates/collab/src/db/queries/billing_subscriptions.rs @@ -199,6 +199,33 @@ impl Database { pub async fn get_active_zed_pro_billing_subscriptions( &self, + ) -> Result> { + self.transaction(|tx| async move { + let mut rows = billing_subscription::Entity::find() + .inner_join(billing_customer::Entity) + .select_also(billing_customer::Entity) + .filter( + billing_subscription::Column::StripeSubscriptionStatus + .eq(StripeSubscriptionStatus::Active), + ) + .filter(billing_subscription::Column::Kind.eq(SubscriptionKind::ZedPro)) + .order_by_asc(billing_subscription::Column::Id) + .stream(&*tx) + .await?; + + let mut subscriptions = HashMap::default(); + while let Some(row) = rows.next().await { + if let (subscription, Some(customer)) = row? { + subscriptions.insert(customer.user_id, (customer, subscription)); + } + } + Ok(subscriptions) + }) + .await + } + + pub async fn get_active_zed_pro_billing_subscriptions_for_users( + &self, user_ids: HashSet, ) -> Result> { self.transaction(|tx| { diff --git a/crates/collab/src/db/queries/servers.rs b/crates/collab/src/db/queries/servers.rs index 73deaaffb68f2c50bb38d2d08fa71782e4600123..da6ff77cf0c5405834939e346ba1ea613199d430 100644 --- a/crates/collab/src/db/queries/servers.rs +++ b/crates/collab/src/db/queries/servers.rs @@ -142,6 +142,50 @@ impl Database { } } + loop { + let delete_query = Query::delete() + .from_table(project_repository_statuses::Entity) + .and_where( + Expr::tuple([Expr::col(( + project_repository_statuses::Entity, + project_repository_statuses::Column::ProjectId, + )) + .into()]) + .in_subquery( + Query::select() + .columns([( + project_repository_statuses::Entity, + project_repository_statuses::Column::ProjectId, + )]) + .from(project_repository_statuses::Entity) + .inner_join( + project::Entity, + Expr::col((project::Entity, project::Column::Id)).equals(( + project_repository_statuses::Entity, + project_repository_statuses::Column::ProjectId, + )), + ) + .and_where(project::Column::HostConnectionServerId.ne(server_id)) + .limit(10000) + .to_owned(), + ), + ) + .to_owned(); + + let statement = Statement::from_sql_and_values( + tx.get_database_backend(), + delete_query + .to_string(sea_orm::sea_query::PostgresQueryBuilder) + .as_str(), + vec![], + ); + + let result = tx.execute(statement).await?; + if result.rows_affected() == 0 { + break; + } + } + Ok(()) }) .await diff --git a/crates/collab/src/db/tests/user_tests.rs b/crates/collab/src/db/tests/user_tests.rs index bb2dac1f77e38fae95160fa7899217bcb981ed43..dd61da55ca001a0424aaeafb0411f8a7de343795 100644 --- a/crates/collab/src/db/tests/user_tests.rs +++ b/crates/collab/src/db/tests/user_tests.rs @@ -44,3 +44,53 @@ async fn test_accepted_tos(db: &Arc) { let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); assert!(user.accepted_tos_at.is_none()); } + +test_both_dbs!( + test_destroy_user_cascade_deletes_access_tokens, + test_destroy_user_cascade_deletes_access_tokens_postgres, + test_destroy_user_cascade_deletes_access_tokens_sqlite +); + +async fn test_destroy_user_cascade_deletes_access_tokens(db: &Arc) { + let user_id = db + .create_user( + "user1@example.com", + Some("user1"), + false, + NewUserParams { + github_login: "user1".to_string(), + github_user_id: 12345, + }, + ) + .await + .unwrap() + .user_id; + + let user = db.get_user_by_id(user_id).await.unwrap(); + assert!(user.is_some()); + + let token_1_id = db + .create_access_token(user_id, None, "token-1", 10) + .await + .unwrap(); + + let token_2_id = db + .create_access_token(user_id, None, "token-2", 10) + .await + .unwrap(); + + let token_1 = db.get_access_token(token_1_id).await; + let token_2 = db.get_access_token(token_2_id).await; + assert!(token_1.is_ok()); + assert!(token_2.is_ok()); + + db.destroy_user(user_id).await.unwrap(); + + let user = db.get_user_by_id(user_id).await.unwrap(); + assert!(user.is_none()); + + let token_1 = db.get_access_token(token_1_id).await; + let token_2 = db.get_access_token(token_2_id).await; + assert!(token_1.is_err()); + assert!(token_2.is_err()); +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 753e591914f45ea962367130d1ecce9a4fd2620f..7a454e11cfced2fa7f9f1dc8c0263934830c7cad 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2836,60 +2836,115 @@ async fn make_update_user_plan_message( account_too_young: Some(account_too_young), has_overdue_invoices: billing_customer .map(|billing_customer| billing_customer.has_overdue_invoices), - usage: usage.map(|usage| { - let plan = match plan { - proto::Plan::Free => zed_llm_client::Plan::ZedFree, - proto::Plan::ZedPro => zed_llm_client::Plan::ZedPro, - proto::Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + usage: Some( + usage + .map(|usage| subscription_usage_to_proto(plan, usage, &feature_flags)) + .unwrap_or_else(|| make_default_subscription_usage(plan, &feature_flags)), + ), + }) +} + +fn model_requests_limit( + plan: zed_llm_client::Plan, + feature_flags: &Vec, +) -> zed_llm_client::UsageLimit { + match plan.model_requests_limit() { + zed_llm_client::UsageLimit::Limited(limit) => { + let limit = if plan == zed_llm_client::Plan::ZedProTrial + && feature_flags + .iter() + .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG) + { + 1_000 + } else { + limit }; - let model_requests_limit = match plan.model_requests_limit() { - zed_llm_client::UsageLimit::Limited(limit) => { - let limit = if plan == zed_llm_client::Plan::ZedProTrial - && feature_flags - .iter() - .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG) - { - 1_000 - } else { - limit - }; + zed_llm_client::UsageLimit::Limited(limit) + } + zed_llm_client::UsageLimit::Unlimited => zed_llm_client::UsageLimit::Unlimited, + } +} - zed_llm_client::UsageLimit::Limited(limit) +fn subscription_usage_to_proto( + plan: proto::Plan, + usage: crate::llm::db::subscription_usage::Model, + feature_flags: &Vec, +) -> proto::SubscriptionUsage { + let plan = match plan { + proto::Plan::Free => zed_llm_client::Plan::ZedFree, + proto::Plan::ZedPro => zed_llm_client::Plan::ZedPro, + proto::Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + }; + + proto::SubscriptionUsage { + model_requests_usage_amount: usage.model_requests as u32, + model_requests_usage_limit: Some(proto::UsageLimit { + variant: Some(match model_requests_limit(plan, feature_flags) { + zed_llm_client::UsageLimit::Limited(limit) => { + proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { + limit: limit as u32, + }) } - zed_llm_client::UsageLimit::Unlimited => zed_llm_client::UsageLimit::Unlimited, - }; + zed_llm_client::UsageLimit::Unlimited => { + proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) + } + }), + }), + edit_predictions_usage_amount: usage.edit_predictions as u32, + edit_predictions_usage_limit: Some(proto::UsageLimit { + variant: Some(match plan.edit_predictions_limit() { + zed_llm_client::UsageLimit::Limited(limit) => { + proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { + limit: limit as u32, + }) + } + zed_llm_client::UsageLimit::Unlimited => { + proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) + } + }), + }), + } +} - proto::SubscriptionUsage { - model_requests_usage_amount: usage.model_requests as u32, - model_requests_usage_limit: Some(proto::UsageLimit { - variant: Some(match model_requests_limit { - zed_llm_client::UsageLimit::Limited(limit) => { - proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { - limit: limit as u32, - }) - } - zed_llm_client::UsageLimit::Unlimited => { - proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) - } - }), - }), - edit_predictions_usage_amount: usage.edit_predictions as u32, - edit_predictions_usage_limit: Some(proto::UsageLimit { - variant: Some(match plan.edit_predictions_limit() { - zed_llm_client::UsageLimit::Limited(limit) => { - proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { - limit: limit as u32, - }) - } - zed_llm_client::UsageLimit::Unlimited => { - proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) - } - }), - }), - } +fn make_default_subscription_usage( + plan: proto::Plan, + feature_flags: &Vec, +) -> proto::SubscriptionUsage { + let plan = match plan { + proto::Plan::Free => zed_llm_client::Plan::ZedFree, + proto::Plan::ZedPro => zed_llm_client::Plan::ZedPro, + proto::Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + }; + + proto::SubscriptionUsage { + model_requests_usage_amount: 0, + model_requests_usage_limit: Some(proto::UsageLimit { + variant: Some(match model_requests_limit(plan, feature_flags) { + zed_llm_client::UsageLimit::Limited(limit) => { + proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { + limit: limit as u32, + }) + } + zed_llm_client::UsageLimit::Unlimited => { + proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) + } + }), }), - }) + edit_predictions_usage_amount: 0, + edit_predictions_usage_limit: Some(proto::UsageLimit { + variant: Some(match plan.edit_predictions_limit() { + zed_llm_client::UsageLimit::Limited(limit) => { + proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { + limit: limit as u32, + }) + } + zed_llm_client::UsageLimit::Unlimited => { + proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) + } + }), + }), + } } async fn update_user_plan(session: &Session) -> Result<()> { diff --git a/crates/collab/src/stripe_billing.rs b/crates/collab/src/stripe_billing.rs index 8bf6c08158b9fa742f0f9e59711c7df80013614d..fdd9653d7cd4cbfeb63e65892c7ccce312c97d97 100644 --- a/crates/collab/src/stripe_billing.rs +++ b/crates/collab/src/stripe_billing.rs @@ -19,8 +19,8 @@ use crate::stripe_client::{ StripeCustomerId, StripeCustomerUpdate, StripeCustomerUpdateAddress, StripeCustomerUpdateName, StripeMeter, StripePrice, StripePriceId, StripeSubscription, StripeSubscriptionId, StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior, - StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems, - UpdateSubscriptionParams, + StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, StripeTaxIdCollection, + UpdateSubscriptionItems, UpdateSubscriptionParams, }; pub struct StripeBilling { @@ -252,6 +252,7 @@ impl StripeBilling { name: Some(StripeCustomerUpdateName::Auto), shipping: None, }); + params.tax_id_collection = Some(StripeTaxIdCollection { enabled: true }); let session = self.client.create_checkout_session(params).await?; Ok(session.url.context("no checkout session URL")?) @@ -311,6 +312,7 @@ impl StripeBilling { name: Some(StripeCustomerUpdateName::Auto), shipping: None, }); + params.tax_id_collection = Some(StripeTaxIdCollection { enabled: true }); let session = self.client.create_checkout_session(params).await?; Ok(session.url.context("no checkout session URL")?) diff --git a/crates/collab/src/stripe_client.rs b/crates/collab/src/stripe_client.rs index 9ffcb2ba6c9fde13ebc84b9e7c509851158e0a1e..ec947e12f792661578f9a8a675a0017f321e8fc4 100644 --- a/crates/collab/src/stripe_client.rs +++ b/crates/collab/src/stripe_client.rs @@ -190,6 +190,7 @@ pub struct StripeCreateCheckoutSessionParams<'a> { pub success_url: Option<&'a str>, pub billing_address_collection: Option, pub customer_update: Option, + pub tax_id_collection: Option, } #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -218,6 +219,11 @@ pub struct StripeCreateCheckoutSessionSubscriptionData { pub trial_settings: Option, } +#[derive(Debug, PartialEq, Clone)] +pub struct StripeTaxIdCollection { + pub enabled: bool, +} + #[derive(Debug)] pub struct StripeCheckoutSession { pub url: Option, diff --git a/crates/collab/src/stripe_client/fake_stripe_client.rs b/crates/collab/src/stripe_client/fake_stripe_client.rs index 11b210dd0e7aba54148d26de0670f23415ae7cea..9bb08443ec6a5fd04ad11a8e24b1a71b03e4867b 100644 --- a/crates/collab/src/stripe_client/fake_stripe_client.rs +++ b/crates/collab/src/stripe_client/fake_stripe_client.rs @@ -14,8 +14,8 @@ use crate::stripe_client::{ StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams, StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate, StripeMeter, StripeMeterId, StripePrice, StripePriceId, StripeSubscription, - StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId, UpdateCustomerParams, - UpdateSubscriptionParams, + StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId, StripeTaxIdCollection, + UpdateCustomerParams, UpdateSubscriptionParams, }; #[derive(Debug, Clone)] @@ -38,6 +38,7 @@ pub struct StripeCreateCheckoutSessionCall { pub success_url: Option, pub billing_address_collection: Option, pub customer_update: Option, + pub tax_id_collection: Option, } pub struct FakeStripeClient { @@ -236,6 +237,7 @@ impl StripeClient for FakeStripeClient { success_url: params.success_url.map(|url| url.to_string()), billing_address_collection: params.billing_address_collection, customer_update: params.customer_update, + tax_id_collection: params.tax_id_collection, }); Ok(StripeCheckoutSession { diff --git a/crates/collab/src/stripe_client/real_stripe_client.rs b/crates/collab/src/stripe_client/real_stripe_client.rs index 7108e8d7597a3afd235c2ae48a4b05c5fc5de014..07dde68d179d8650ef902ae07b05015bf5aa2633 100644 --- a/crates/collab/src/stripe_client/real_stripe_client.rs +++ b/crates/collab/src/stripe_client/real_stripe_client.rs @@ -27,8 +27,8 @@ use crate::stripe_client::{ StripeMeter, StripePrice, StripePriceId, StripePriceRecurring, StripeSubscription, StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId, StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior, - StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateCustomerParams, - UpdateSubscriptionParams, + StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, StripeTaxIdCollection, + UpdateCustomerParams, UpdateSubscriptionParams, }; pub struct RealStripeClient { @@ -448,6 +448,7 @@ impl<'a> TryFrom> for CreateCheckoutSessio success_url: value.success_url, billing_address_collection: value.billing_address_collection.map(Into::into), customer_update: value.customer_update.map(Into::into), + tax_id_collection: value.tax_id_collection.map(Into::into), ..Default::default() }) } @@ -590,3 +591,11 @@ impl From for stripe::CreateCheckoutSessionCustomerUpdate } } } + +impl From for stripe::CreateCheckoutSessionTaxIdCollection { + fn from(value: StripeTaxIdCollection) -> Self { + stripe::CreateCheckoutSessionTaxIdCollection { + enabled: value.enabled, + } + } +} diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index a77112213f195190e613c2382300bfbbeca70066..3aa86a434dac611be260eb7f281d9067812c15ac 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1013,7 +1013,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T // and some of which were originally opened by client B. workspace_b.update_in(cx_b, |workspace, window, cx| { workspace.active_pane().update(cx, |pane, cx| { - pane.close_inactive_items(&Default::default(), window, cx) + pane.close_inactive_items(&Default::default(), None, window, cx) .detach(); }); }); diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 7aeb381c02beeb6165e44ccd5bbd72f5744cc964..8ab6e6910c88880bc8b6451d972e39b5c2315812 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -2,6 +2,7 @@ use crate::tests::TestServer; use call::ActiveCall; use collections::{HashMap, HashSet}; +use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling}; use debugger_ui::debugger_panel::DebugPanel; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; @@ -22,6 +23,7 @@ use language::{ use node_runtime::NodeRuntime; use project::{ ProjectPath, + debugger::session::ThreadId, lsp_store::{FormatTrigger, LspFormatTarget}, }; use remote::SshRemoteClient; @@ -29,7 +31,11 @@ use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; use settings::SettingsStore; -use std::{path::Path, sync::Arc}; +use std::{ + path::Path, + sync::{Arc, atomic::AtomicUsize}, +}; +use task::TcpArgumentsTemplate; use util::path; #[gpui::test(iterations = 10)] @@ -688,3 +694,162 @@ async fn test_remote_server_debugger( shutdown_session.await.unwrap(); } + +#[gpui::test] +async fn test_slow_adapter_startup_retries( + cx_a: &mut TestAppContext, + server_cx: &mut TestAppContext, + executor: BackgroundExecutor, +) { + cx_a.update(|cx| { + release_channel::init(SemanticVersion::default(), cx); + command_palette_hooks::init(cx); + zlog::init_test(); + dap_adapters::init(cx); + }); + server_cx.update(|cx| { + release_channel::init(SemanticVersion::default(), cx); + dap_adapters::init(cx); + }); + let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree( + path!("/code"), + json!({ + "lib.rs": "fn one() -> usize { 1 }" + }), + ) + .await; + + // User A connects to the remote project via SSH. + server_cx.update(HeadlessProject::init); + let remote_http_client = Arc::new(BlockedHttpClient); + let node = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + let _headless_project = server_cx.new(|cx| { + client::init_settings(cx); + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: remote_http_client, + node_runtime: node, + languages, + extension_host_proxy: Arc::new(ExtensionHostProxy::new()), + }, + cx, + ) + }); + + let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let mut server = TestServer::start(server_cx.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + cx_a.update(|cx| { + debugger_ui::init(cx); + command_palette_hooks::init(cx); + }); + let (project_a, _) = client_a + .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a) + .await; + + let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a); + + let debugger_panel = workspace + .update_in(cx_a, |_workspace, window, cx| { + cx.spawn_in(window, DebugPanel::load) + }) + .await + .unwrap(); + + workspace.update_in(cx_a, |workspace, window, cx| { + workspace.add_panel(debugger_panel, window, cx); + }); + + cx_a.run_until_parked(); + let debug_panel = workspace + .update(cx_a, |workspace, cx| workspace.panel::(cx)) + .unwrap(); + + let workspace_window = cx_a + .window_handle() + .downcast::() + .unwrap(); + + let count = Arc::new(AtomicUsize::new(0)); + let session = debugger_ui::tests::start_debug_session_with( + &workspace_window, + cx_a, + DebugTaskDefinition { + adapter: "fake-adapter".into(), + label: "test".into(), + config: json!({ + "request": "launch" + }), + tcp_connection: Some(TcpArgumentsTemplate { + port: None, + host: None, + timeout: None, + }), + }, + move |client| { + let count = count.clone(); + client.on_request_ext::(move |_seq, _request| { + if count.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 5 { + return RequestHandling::Exit; + } + RequestHandling::Respond(Ok(Capabilities::default())) + }); + }, + ) + .unwrap(); + cx_a.run_until_parked(); + + let client = session.update(cx_a, |session, _| session.adapter_client().unwrap()); + 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_a.run_until_parked(); + + let active_session = debug_panel + .update(cx_a, |this, _| this.active_session()) + .unwrap(); + + let running_state = active_session.update(cx_a, |active_session, _| { + active_session.running_state().clone() + }); + + assert_eq!( + client.id(), + running_state.read_with(cx_a, |running_state, _| running_state.session_id()) + ); + assert_eq!( + ThreadId(1), + running_state.read_with(cx_a, |running_state, _| running_state + .selected_thread_id() + .unwrap()) + ); + + let shutdown_session = workspace.update(cx_a, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }) + }); + + client_ssh.update(cx_a, |a, _| { + a.shutdown_processes(Some(proto::ShutdownRemoteServer {}), executor) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index c872f99aa10ee160ed499621d9aceb2aa7c06a05..b86d72d92faede8c52e40a8e209fde5bf1ea9f0b 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -30,7 +30,13 @@ use workspace::{ }; use workspace::{item::Dedup, notifications::NotificationId}; -actions!(collab, [CopyLink]); +actions!( + collab, + [ + /// Copies a link to the current position in the channel buffer. + CopyLink + ] +); pub fn init(cx: &mut App) { workspace::FollowableViewRegistry::register::(cx) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 54c45a9fec39569d06d7dc45140affe4f6c27d5b..3e2d813f1ba6474dc9e089d1fde2d48b26c7a31a 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -71,7 +71,13 @@ struct SerializedChatPanel { width: Option, } -actions!(chat_panel, [ToggleFocus]); +actions!( + chat_panel, + [ + /// Toggles focus on the chat panel. + ToggleFocus + ] +); impl ChatPanel { pub fn new( diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 6501d3a56649ec3cb2ef15099829d601fbbfadd4..ec23e2c3f536dc38db05f448f0d239d243a15756 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -44,15 +44,25 @@ use workspace::{ actions!( collab_panel, [ + /// Toggles focus on the collaboration panel. ToggleFocus, + /// Removes the selected channel or contact. Remove, + /// Opens the context menu for the selected item. Secondary, + /// Collapses the selected channel in the tree view. CollapseSelectedChannel, + /// Expands the selected channel in the tree view. ExpandSelectedChannel, + /// Starts moving a channel to a new location. StartMoveChannel, + /// Moves the selected item to the current location. MoveSelected, + /// Inserts a space character in the filter input. InsertSpace, + /// Moves the selected channel up in the list. MoveChannelUp, + /// Moves the selected channel down in the list. MoveChannelDown, ] ); diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9af7baa160a1cfea3f2894f9dc1f0cf5522c2740..c0d3130ee997e3fe2ffffc4b228de9e512f18340 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -17,9 +17,13 @@ use workspace::{ModalView, notifications::DetachAndPromptErr}; actions!( channel_modal, [ + /// Selects the next control in the channel modal. SelectNextControl, + /// Toggles between invite members and manage members mode. ToggleMode, + /// Toggles admin status for the selected member. ToggleMemberAdmin, + /// Removes the selected member from the channel. RemoveMember ] ); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 5e5e8164f9202fa978660e50a9375ad828ace92a..fba8f66c2d19153a0288148b02e593ee37078fb0 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -74,7 +74,13 @@ pub struct NotificationPresenter { pub can_navigate: bool, } -actions!(notification_panel, [ToggleFocus]); +actions!( + notification_panel, + [ + /// Toggles focus on the notification panel. + ToggleFocus + ] +); pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { diff --git a/crates/component/src/component_layout.rs b/crates/component/src/component_layout.rs index 9090c49cf9fea1ebe8e742e0b08c462dea3a2ae6..b749ea20eab8b347b83bf34e35c33ec4ef5c614f 100644 --- a/crates/component/src/component_layout.rs +++ b/crates/component/src/component_layout.rs @@ -61,7 +61,7 @@ impl RenderOnce for ComponentExample { 12.0, 12.0, )) - .shadow_sm() + .shadow_xs() .child(self.element), ) .into_any_element() diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 51f0984a1f55313dbfa4f834d3aa35933b4baba7..e4370d2e67cef9c5c4db68123edfb7dca5d7fa00 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -46,11 +46,17 @@ pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_an actions!( copilot, [ + /// Requests a code completion suggestion from Copilot. Suggest, + /// Cycles to the next Copilot suggestion. NextSuggestion, + /// Cycles to the previous Copilot suggestion. PreviousSuggestion, + /// Reinstalls the Copilot language server. Reinstall, + /// Signs in to GitHub Copilot. SignIn, + /// Signs out of GitHub Copilot. SignOut ] ); diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index b1fa1565f30ed79fdff763964708fe01c62d023f..4c91b4fedb790ab3500273ff21aba767cacd28e0 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -528,6 +528,7 @@ impl CopilotChat { pub async fn stream_completion( request: Request, + is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { let this = cx @@ -562,7 +563,14 @@ impl CopilotChat { }; let api_url = configuration.api_url_from_endpoint(&token.api_endpoint); - stream_completion(client.clone(), token.api_key, api_url.into(), request).await + stream_completion( + client.clone(), + token.api_key, + api_url.into(), + request, + is_user_initiated, + ) + .await } pub fn set_configuration( @@ -697,6 +705,7 @@ async fn stream_completion( api_key: String, completion_url: Arc, request: Request, + is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { ChatMessage::User { content } @@ -707,6 +716,8 @@ async fn stream_completion( _ => false, }); + let request_initiator = if is_user_initiated { "user" } else { "agent" }; + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(completion_url.as_ref()) @@ -719,7 +730,8 @@ async fn stream_completion( ) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") - .header("Copilot-Integration-Id", "vscode-chat"); + .header("Copilot-Integration-Id", "vscode-chat") + .header("X-Initiator", request_initiator); if is_vision_request { request_builder = diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index d9f26b3b348985f2e52423cb217b1c1446960bbf..0c88f37ff8bfaad92ef5d6223b43c9bd6d91ad1d 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -378,6 +378,14 @@ pub trait DebugAdapter: 'static + Send + Sync { fn label_for_child_session(&self, _args: &StartDebuggingRequestArguments) -> Option { None } + + fn compact_child_session(&self) -> bool { + false + } + + fn prefer_thread_name(&self) -> bool { + false + } } #[cfg(any(test, feature = "test-support"))] @@ -442,10 +450,18 @@ impl DebugAdapter for FakeAdapter { _: Option>, _: &mut AsyncApp, ) -> Result { + let connection = task_definition + .tcp_connection + .as_ref() + .map(|connection| TcpArguments { + host: connection.host(), + port: connection.port.unwrap_or(17), + timeout: connection.timeout, + }); Ok(DebugAdapterBinary { command: Some("command".into()), arguments: vec![], - connection: None, + connection, envs: HashMap::default(), cwd: None, request_args: StartDebuggingRequestArguments { diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index 4515e2a1d723f0701b53723e472bd8c5013ffa65..86a15b2d8a9fa3ce00cdaa2536fb3decce948aec 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -108,7 +108,9 @@ impl DebugAdapterClient { arguments: Some(serialized_arguments), }; self.transport_delegate - .add_pending_request(sequence_id, callback_tx); + .pending_requests + .lock() + .insert(sequence_id, callback_tx)?; log::debug!( "Client {} send `{}` request with sequence_id: {}", @@ -166,6 +168,7 @@ impl DebugAdapterClient { pub fn kill(&self) { log::debug!("Killing DAP process"); self.transport_delegate.transport.lock().kill(); + self.transport_delegate.pending_requests.lock().shutdown(); } pub fn has_adapter_logs(&self) -> bool { @@ -180,11 +183,34 @@ impl DebugAdapterClient { } #[cfg(any(test, feature = "test-support"))] - pub fn on_request(&self, handler: F) + pub fn on_request(&self, mut handler: F) where F: 'static + Send + FnMut(u64, R::Arguments) -> Result, + { + use crate::transport::RequestHandling; + + self.transport_delegate + .transport + .lock() + .as_fake() + .on_request::(move |seq, request| { + RequestHandling::Respond(handler(seq, request)) + }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn on_request_ext(&self, handler: F) + where + F: 'static + + Send + + FnMut( + u64, + R::Arguments, + ) -> crate::transport::RequestHandling< + Result, + >, { self.transport_delegate .transport diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs index 9576608ab0aa6267ecfed248106c7ca1c5d60654..6dadf1cf35d0fc01f91b55a471e2be1e0c7b1be9 100644 --- a/crates/dap/src/transport.rs +++ b/crates/dap/src/transport.rs @@ -49,7 +49,12 @@ pub enum IoKind { StdErr, } -type Requests = Arc>>>>; +#[cfg(any(test, feature = "test-support"))] +pub enum RequestHandling { + Respond(T), + Exit, +} + type LogHandlers = Arc>>; pub trait Transport: Send + Sync { @@ -77,7 +82,11 @@ async fn start( ) -> Result> { #[cfg(any(test, feature = "test-support"))] if cfg!(any(test, feature = "test-support")) { - return Ok(Box::new(FakeTransport::start(cx).await?)); + if let Some(connection) = binary.connection.clone() { + return Ok(Box::new(FakeTransport::start_tcp(connection, cx).await?)); + } else { + return Ok(Box::new(FakeTransport::start_stdio(cx).await?)); + } } if binary.connection.is_some() { @@ -91,20 +100,62 @@ async fn start( } } +pub(crate) struct PendingRequests { + inner: Option>>>, +} + +impl PendingRequests { + fn new() -> Self { + Self { + inner: Some(HashMap::default()), + } + } + + fn flush(&mut self, e: anyhow::Error) { + let Some(inner) = self.inner.as_mut() else { + return; + }; + for (_, sender) in inner.drain() { + sender.send(Err(e.cloned())).ok(); + } + } + + pub(crate) fn insert( + &mut self, + sequence_id: u64, + callback_tx: oneshot::Sender>, + ) -> anyhow::Result<()> { + let Some(inner) = self.inner.as_mut() else { + bail!("client is closed") + }; + inner.insert(sequence_id, callback_tx); + Ok(()) + } + + pub(crate) fn remove( + &mut self, + sequence_id: u64, + ) -> anyhow::Result>>> { + let Some(inner) = self.inner.as_mut() else { + bail!("client is closed"); + }; + Ok(inner.remove(&sequence_id)) + } + + pub(crate) fn shutdown(&mut self) { + self.flush(anyhow!("transport shutdown")); + self.inner = None; + } +} + pub(crate) struct TransportDelegate { log_handlers: LogHandlers, - pub(crate) pending_requests: Requests, + pub(crate) pending_requests: Arc>, pub(crate) transport: Mutex>, pub(crate) server_tx: smol::lock::Mutex>>, tasks: Mutex>>, } -impl Drop for TransportDelegate { - fn drop(&mut self) { - self.transport.lock().kill() - } -} - impl TransportDelegate { pub(crate) async fn start(binary: &DebugAdapterBinary, cx: &mut AsyncApp) -> Result { let log_handlers: LogHandlers = Default::default(); @@ -113,7 +164,7 @@ impl TransportDelegate { transport: Mutex::new(transport), log_handlers, server_tx: Default::default(), - pending_requests: Default::default(), + pending_requests: Arc::new(Mutex::new(PendingRequests::new())), tasks: Default::default(), }) } @@ -154,16 +205,12 @@ impl TransportDelegate { .await { Ok(()) => { - pending_requests.lock().drain().for_each(|(_, request)| { - request - .send(Err(anyhow!("debugger shutdown unexpectedly"))) - .ok(); - }); + pending_requests + .lock() + .flush(anyhow!("debugger shutdown unexpectedly")); } Err(e) => { - pending_requests.lock().drain().for_each(|(_, request)| { - request.send(Err(e.cloned())).ok(); - }); + pending_requests.lock().flush(e); } } })); @@ -188,15 +235,6 @@ impl TransportDelegate { self.transport.lock().tcp_arguments() } - pub(crate) fn add_pending_request( - &self, - sequence_id: u64, - request: oneshot::Sender>, - ) { - let mut pending_requests = self.pending_requests.lock(); - pending_requests.insert(sequence_id, request); - } - pub(crate) async fn send_message(&self, message: Message) -> Result<()> { if let Some(server_tx) = self.server_tx.lock().await.as_ref() { server_tx.send(message).await.context("sending message") @@ -290,7 +328,7 @@ impl TransportDelegate { async fn recv_from_server( server_stdout: Stdout, mut message_handler: DapMessageHandler, - pending_requests: Requests, + pending_requests: Arc>, log_handlers: Option, ) -> Result<()> where @@ -300,16 +338,17 @@ impl TransportDelegate { let mut reader = BufReader::new(server_stdout); let result = loop { - match Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref()) - .await - { + let result = + Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref()) + .await; + match result { ConnectionResult::Timeout => anyhow::bail!("Timed out when connecting to debugger"), ConnectionResult::ConnectionReset => { log::info!("Debugger closed the connection"); return Ok(()); } ConnectionResult::Result(Ok(Message::Response(res))) => { - let tx = pending_requests.lock().remove(&res.request_seq); + let tx = pending_requests.lock().remove(res.request_seq)?; if let Some(tx) = tx { if let Err(e) = tx.send(Self::process_response(res)) { log::trace!("Did not send response `{:?}` for a cancelled", e); @@ -703,8 +742,7 @@ impl Drop for StdioTransport { } #[cfg(any(test, feature = "test-support"))] -type RequestHandler = - Box dap_types::messages::Response>; +type RequestHandler = Box RequestHandling>; #[cfg(any(test, feature = "test-support"))] type ResponseHandler = Box; @@ -715,23 +753,38 @@ pub struct FakeTransport { request_handlers: Arc>>, // for reverse request responses response_handlers: Arc>>, - - stdin_writer: Option, - stdout_reader: Option, message_handler: Option>>, + kind: FakeTransportKind, +} + +#[cfg(any(test, feature = "test-support"))] +pub enum FakeTransportKind { + Stdio { + stdin_writer: Option, + stdout_reader: Option, + }, + Tcp { + connection: TcpArguments, + executor: BackgroundExecutor, + }, } #[cfg(any(test, feature = "test-support"))] impl FakeTransport { pub fn on_request(&self, mut handler: F) where - F: 'static + Send + FnMut(u64, R::Arguments) -> Result, + F: 'static + + Send + + FnMut(u64, R::Arguments) -> RequestHandling>, { self.request_handlers.lock().insert( R::COMMAND, Box::new(move |seq, args| { let result = handler(seq, serde_json::from_value(args).unwrap()); - let response = match result { + let RequestHandling::Respond(response) = result else { + return RequestHandling::Exit; + }; + let response = match response { Ok(response) => Response { seq: seq + 1, request_seq: seq, @@ -749,7 +802,7 @@ impl FakeTransport { message: None, }, }; - response + RequestHandling::Respond(response) }), ); } @@ -763,86 +816,75 @@ impl FakeTransport { .insert(R::COMMAND, Box::new(handler)); } - async fn start(cx: &mut AsyncApp) -> Result { - use dap_types::requests::{Request, RunInTerminal, StartDebugging}; - use serde_json::json; - - let (stdin_writer, stdin_reader) = async_pipe::pipe(); - let (stdout_writer, stdout_reader) = async_pipe::pipe(); - - let mut this = Self { + async fn start_tcp(connection: TcpArguments, cx: &mut AsyncApp) -> Result { + Ok(Self { request_handlers: Arc::new(Mutex::new(HashMap::default())), response_handlers: Arc::new(Mutex::new(HashMap::default())), - stdin_writer: Some(stdin_writer), - stdout_reader: Some(stdout_reader), message_handler: None, - }; + kind: FakeTransportKind::Tcp { + connection, + executor: cx.background_executor().clone(), + }, + }) + } - let request_handlers = this.request_handlers.clone(); - let response_handlers = this.response_handlers.clone(); + async fn handle_messages( + request_handlers: Arc>>, + response_handlers: Arc>>, + stdin_reader: PipeReader, + stdout_writer: PipeWriter, + ) -> Result<()> { + use dap_types::requests::{Request, RunInTerminal, StartDebugging}; + use serde_json::json; + + let mut reader = BufReader::new(stdin_reader); let stdout_writer = Arc::new(smol::lock::Mutex::new(stdout_writer)); + let mut buffer = String::new(); - this.message_handler = Some(cx.background_spawn(async move { - let mut reader = BufReader::new(stdin_reader); - let mut buffer = String::new(); + loop { + match TransportDelegate::receive_server_message(&mut reader, &mut buffer, None).await { + ConnectionResult::Timeout => { + anyhow::bail!("Timed out when connecting to debugger"); + } + ConnectionResult::ConnectionReset => { + log::info!("Debugger closed the connection"); + break Ok(()); + } + ConnectionResult::Result(Err(e)) => break Err(e), + ConnectionResult::Result(Ok(message)) => { + match message { + Message::Request(request) => { + // redirect reverse requests to stdout writer/reader + if request.command == RunInTerminal::COMMAND + || request.command == StartDebugging::COMMAND + { + let message = + serde_json::to_string(&Message::Request(request)).unwrap(); - loop { - match TransportDelegate::receive_server_message(&mut reader, &mut buffer, None) - .await - { - ConnectionResult::Timeout => { - anyhow::bail!("Timed out when connecting to debugger"); - } - ConnectionResult::ConnectionReset => { - log::info!("Debugger closed the connection"); - break Ok(()); - } - ConnectionResult::Result(Err(e)) => break Err(e), - ConnectionResult::Result(Ok(message)) => { - match message { - Message::Request(request) => { - // redirect reverse requests to stdout writer/reader - if request.command == RunInTerminal::COMMAND - || request.command == StartDebugging::COMMAND + let mut writer = stdout_writer.lock().await; + writer + .write_all( + TransportDelegate::build_rpc_message(message).as_bytes(), + ) + .await + .unwrap(); + writer.flush().await.unwrap(); + } else { + let response = if let Some(handle) = + request_handlers.lock().get_mut(request.command.as_str()) { - let message = - serde_json::to_string(&Message::Request(request)).unwrap(); - - let mut writer = stdout_writer.lock().await; - writer - .write_all( - TransportDelegate::build_rpc_message(message) - .as_bytes(), - ) - .await - .unwrap(); - writer.flush().await.unwrap(); + handle(request.seq, request.arguments.unwrap_or(json!({}))) } else { - let response = if let Some(handle) = - request_handlers.lock().get_mut(request.command.as_str()) - { - handle(request.seq, request.arguments.unwrap_or(json!({}))) - } else { - panic!("No request handler for {}", request.command); - }; - let message = - serde_json::to_string(&Message::Response(response)) - .unwrap(); - - let mut writer = stdout_writer.lock().await; - writer - .write_all( - TransportDelegate::build_rpc_message(message) - .as_bytes(), - ) - .await - .unwrap(); - writer.flush().await.unwrap(); - } - } - Message::Event(event) => { + panic!("No request handler for {}", request.command); + }; + let response = match response { + RequestHandling::Respond(response) => response, + RequestHandling::Exit => { + break Err(anyhow!("exit in response to request")); + } + }; let message = - serde_json::to_string(&Message::Event(event)).unwrap(); + serde_json::to_string(&Message::Response(response)).unwrap(); let mut writer = stdout_writer.lock().await; writer @@ -853,20 +895,56 @@ impl FakeTransport { .unwrap(); writer.flush().await.unwrap(); } - Message::Response(response) => { - if let Some(handle) = - response_handlers.lock().get(response.command.as_str()) - { - handle(response); - } else { - log::error!("No response handler for {}", response.command); - } + } + Message::Event(event) => { + let message = serde_json::to_string(&Message::Event(event)).unwrap(); + + let mut writer = stdout_writer.lock().await; + writer + .write_all(TransportDelegate::build_rpc_message(message).as_bytes()) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + Message::Response(response) => { + if let Some(handle) = + response_handlers.lock().get(response.command.as_str()) + { + handle(response); + } else { + log::error!("No response handler for {}", response.command); } } } } } - })); + } + } + + async fn start_stdio(cx: &mut AsyncApp) -> Result { + let (stdin_writer, stdin_reader) = async_pipe::pipe(); + let (stdout_writer, stdout_reader) = async_pipe::pipe(); + let kind = FakeTransportKind::Stdio { + stdin_writer: Some(stdin_writer), + stdout_reader: Some(stdout_reader), + }; + + let mut this = Self { + request_handlers: Arc::new(Mutex::new(HashMap::default())), + response_handlers: Arc::new(Mutex::new(HashMap::default())), + message_handler: None, + kind, + }; + + let request_handlers = this.request_handlers.clone(); + let response_handlers = this.response_handlers.clone(); + + this.message_handler = Some(cx.background_spawn(Self::handle_messages( + request_handlers, + response_handlers, + stdin_reader, + stdout_writer, + ))); Ok(this) } @@ -875,7 +953,10 @@ impl FakeTransport { #[cfg(any(test, feature = "test-support"))] impl Transport for FakeTransport { fn tcp_arguments(&self) -> Option { - None + match &self.kind { + FakeTransportKind::Stdio { .. } => None, + FakeTransportKind::Tcp { connection, .. } => Some(connection.clone()), + } } fn connect( @@ -886,12 +967,33 @@ impl Transport for FakeTransport { Box, )>, > { - let result = util::maybe!({ - Ok(( - Box::new(self.stdin_writer.take().context("Cannot reconnect")?) as _, - Box::new(self.stdout_reader.take().context("Cannot reconnect")?) as _, - )) - }); + let result = match &mut self.kind { + FakeTransportKind::Stdio { + stdin_writer, + stdout_reader, + } => util::maybe!({ + Ok(( + Box::new(stdin_writer.take().context("Cannot reconnect")?) as _, + Box::new(stdout_reader.take().context("Cannot reconnect")?) as _, + )) + }), + FakeTransportKind::Tcp { executor, .. } => { + let (stdin_writer, stdin_reader) = async_pipe::pipe(); + let (stdout_writer, stdout_reader) = async_pipe::pipe(); + + let request_handlers = self.request_handlers.clone(); + let response_handlers = self.response_handlers.clone(); + + self.message_handler = Some(executor.spawn(Self::handle_messages( + request_handlers, + response_handlers, + stdin_reader, + stdout_writer, + ))); + + Ok((Box::new(stdin_writer) as _, Box::new(stdout_reader) as _)) + } + }; Task::ready(result) } diff --git a/crates/dap_adapters/src/dap_adapters.rs b/crates/dap_adapters/src/dap_adapters.rs index c254302e7144b53500fd2a3b84be06e8ec30c2a0..a147861f8dc965c7924a70d884004d594d59a949 100644 --- a/crates/dap_adapters/src/dap_adapters.rs +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -2,7 +2,6 @@ mod codelldb; mod gdb; mod go; mod javascript; -mod php; mod python; use std::sync::Arc; @@ -22,7 +21,6 @@ use gdb::GdbDebugAdapter; use go::GoDebugAdapter; use gpui::{App, BorrowAppContext}; use javascript::JsDebugAdapter; -use php::PhpDebugAdapter; use python::PythonDebugAdapter; use serde_json::json; use task::{DebugScenario, ZedDebugConfig}; @@ -31,7 +29,6 @@ pub fn init(cx: &mut App) { cx.update_default_global(|registry: &mut DapRegistry, _cx| { registry.add_adapter(Arc::from(CodeLldbDebugAdapter::default())); registry.add_adapter(Arc::from(PythonDebugAdapter::default())); - registry.add_adapter(Arc::from(PhpDebugAdapter::default())); registry.add_adapter(Arc::from(JsDebugAdapter::default())); registry.add_adapter(Arc::from(GoDebugAdapter::default())); registry.add_adapter(Arc::from(GdbDebugAdapter)); diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs index d32f5cbf3426f1b669132e74e389862e7944267b..22d8262b93e36b17e548ae4dcc9bb725da8ca7cb 100644 --- a/crates/dap_adapters/src/go.rs +++ b/crates/dap_adapters/src/go.rs @@ -547,6 +547,7 @@ async fn handle_envs( } }; + let mut env_vars = HashMap::default(); for path in env_files { let Some(path) = path .and_then(|s| PathBuf::from_str(s).ok()) @@ -556,13 +557,33 @@ async fn handle_envs( }; if let Ok(file) = fs.open_sync(&path).await { - envs.extend(dotenvy::from_read_iter(file).filter_map(Result::ok)) + let file_envs: HashMap = dotenvy::from_read_iter(file) + .filter_map(Result::ok) + .collect(); + envs.extend(file_envs.iter().map(|(k, v)| (k.clone(), v.clone()))); + env_vars.extend(file_envs); } else { warn!("While starting Go debug session: failed to read env file {path:?}"); }; } + let mut env_obj: serde_json::Map = serde_json::Map::new(); + + for (k, v) in env_vars { + env_obj.insert(k, Value::String(v)); + } + + if let Some(existing_env) = config.get("env").and_then(|v| v.as_object()) { + for (k, v) in existing_env { + env_obj.insert(k.clone(), v.clone()); + } + } + + if !env_obj.is_empty() { + config.insert("env".to_string(), Value::Object(env_obj)); + } + // remove envFile now that it's been handled - config.remove("entry"); + config.remove("envFile"); Some(()) } diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index fd48c599591680e6f75178996efa77087e4784fb..a51377cd76dd7ab1702c263378d0bf2904f27a6f 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -1,9 +1,10 @@ use adapters::latest_github_release; use anyhow::Context as _; +use collections::HashMap; use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use gpui::AsyncApp; use serde_json::Value; -use std::{collections::HashMap, path::PathBuf, sync::OnceLock}; +use std::{path::PathBuf, sync::OnceLock}; use task::DebugRequest; use util::{ResultExt, maybe}; @@ -70,6 +71,8 @@ impl JsDebugAdapter { 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 configuration = task_definition.config.clone(); if let Some(configuration) = configuration.as_object_mut() { maybe!({ @@ -79,9 +82,9 @@ impl JsDebugAdapter { let command = configuration.get("command")?.as_str()?.to_owned(); let mut args = shlex::split(&command)?.into_iter(); let program = args.next()?; - configuration.insert("program".to_owned(), program.into()); + configuration.insert("runtimeExecutable".to_owned(), program.into()); configuration.insert( - "args".to_owned(), + "runtimeArgs".to_owned(), args.map(Value::from).collect::>().into(), ); configuration.insert("console".to_owned(), "externalTerminal".into()); @@ -110,6 +113,12 @@ impl JsDebugAdapter { } } + if let Some(env) = configuration.get("env").cloned() { + if let Ok(env) = serde_json::from_value(env) { + envs = env; + } + } + configuration .entry("cwd") .or_insert(delegate.worktree_root_path().to_string_lossy().into()); @@ -158,7 +167,7 @@ impl JsDebugAdapter { ), arguments, cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), + envs, connection: Some(adapters::TcpArguments { host, port, @@ -245,7 +254,7 @@ impl DebugAdapter for JsDebugAdapter { "properties": { "type": { "type": "string", - "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "msedge", "pwa-msedge"], + "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "msedge", "pwa-msedge", "node-terminal"], "description": "The type of debug session", "default": "pwa-node" }, @@ -379,10 +388,6 @@ impl DebugAdapter for JsDebugAdapter { } } }, - "oneOf": [ - { "required": ["program"] }, - { "required": ["url"] } - ] } ] }, @@ -529,6 +534,14 @@ impl DebugAdapter for JsDebugAdapter { .filter(|name| !name.is_empty())?; Some(label.to_owned()) } + + fn compact_child_session(&self) -> bool { + true + } + + fn prefer_thread_name(&self) -> bool { + true + } } fn normalize_task_type(task_type: &mut Value) { diff --git a/crates/dap_adapters/src/php.rs b/crates/dap_adapters/src/php.rs deleted file mode 100644 index 7d7dee00c900dcfa44fc4bf99e164d0f2454c817..0000000000000000000000000000000000000000 --- a/crates/dap_adapters/src/php.rs +++ /dev/null @@ -1,368 +0,0 @@ -use adapters::latest_github_release; -use anyhow::Context as _; -use anyhow::bail; -use dap::StartDebuggingRequestArguments; -use dap::StartDebuggingRequestArgumentsRequest; -use dap::adapters::{DebugTaskDefinition, TcpArguments}; -use gpui::{AsyncApp, SharedString}; -use language::LanguageName; -use std::{collections::HashMap, path::PathBuf, sync::OnceLock}; -use util::ResultExt; - -use crate::*; - -#[derive(Default)] -pub(crate) struct PhpDebugAdapter { - checked: OnceLock<()>, -} - -impl PhpDebugAdapter { - const ADAPTER_NAME: &'static str = "PHP"; - const ADAPTER_PACKAGE_NAME: &'static str = "vscode-php-debug"; - const ADAPTER_PATH: &'static str = "extension/out/phpDebug.js"; - - async fn fetch_latest_adapter_version( - &self, - delegate: &Arc, - ) -> Result { - let release = latest_github_release( - &format!("{}/{}", "xdebug", Self::ADAPTER_PACKAGE_NAME), - true, - false, - delegate.http_client(), - ) - .await?; - - let asset_name = format!("php-debug-{}.vsix", release.tag_name.replace("v", "")); - - Ok(AdapterVersion { - tag_name: release.tag_name, - url: release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .with_context(|| format!("no asset found matching {asset_name:?}"))? - .browser_download_url - .clone(), - }) - } - - async fn get_installed_binary( - &self, - delegate: &Arc, - task_definition: &DebugTaskDefinition, - user_installed_path: Option, - user_args: Option>, - _: &mut AsyncApp, - ) -> Result { - let adapter_path = if let Some(user_installed_path) = user_installed_path { - user_installed_path - } else { - let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref()); - - let file_name_prefix = format!("{}_", self.name()); - - util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { - file_name.starts_with(&file_name_prefix) - }) - .await - .context("Couldn't find PHP dap directory")? - }; - - let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); - let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; - - let mut configuration = task_definition.config.clone(); - if let Some(obj) = configuration.as_object_mut() { - obj.entry("cwd") - .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into()); - } - - let arguments = if let Some(mut args) = user_args { - args.insert( - 0, - adapter_path - .join(Self::ADAPTER_PATH) - .to_string_lossy() - .to_string(), - ); - args - } else { - vec![ - adapter_path - .join(Self::ADAPTER_PATH) - .to_string_lossy() - .to_string(), - format!("--server={}", port), - ] - }; - - Ok(DebugAdapterBinary { - command: Some( - delegate - .node_runtime() - .binary_path() - .await? - .to_string_lossy() - .into_owned(), - ), - arguments, - connection: Some(TcpArguments { - port, - host, - timeout, - }), - cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), - request_args: StartDebuggingRequestArguments { - configuration, - request: ::request_kind(self, &task_definition.config) - .await?, - }, - }) - } -} - -#[async_trait(?Send)] -impl DebugAdapter for PhpDebugAdapter { - fn dap_schema(&self) -> serde_json::Value { - json!({ - "properties": { - "request": { - "type": "string", - "enum": ["launch"], - "description": "The request type for the PHP debug adapter, always \"launch\"", - "default": "launch" - }, - "hostname": { - "type": "string", - "description": "The address to bind to when listening for Xdebug (default: all IPv6 connections if available, else all IPv4 connections) or Unix Domain socket (prefix with unix://) or Windows Pipe (\\\\?\\pipe\\name) - cannot be combined with port" - }, - "port": { - "type": "integer", - "description": "The port on which to listen for Xdebug (default: 9003). If port is set to 0 a random port is chosen by the system and a placeholder ${port} is replaced with the chosen port in env and runtimeArgs.", - "default": 9003 - }, - "program": { - "type": "string", - "description": "The PHP script to debug (typically a path to a file)", - "default": "${file}" - }, - "cwd": { - "type": "string", - "description": "Working directory for the debugged program" - }, - "args": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Command line arguments to pass to the program" - }, - "env": { - "type": "object", - "description": "Environment variables to pass to the program", - "additionalProperties": { - "type": "string" - } - }, - "stopOnEntry": { - "type": "boolean", - "description": "Whether to break at the beginning of the script", - "default": false - }, - "pathMappings": { - "type": "object", - "description": "A mapping of server paths to local paths.", - }, - "log": { - "type": "boolean", - "description": "Whether to log all communication between editor and the adapter to the debug console", - "default": false - }, - "ignore": { - "type": "array", - "description": "An array of glob patterns that errors should be ignored from (for example **/vendor/**/*.php)", - "items": { - "type": "string" - } - }, - "ignoreExceptions": { - "type": "array", - "description": "An array of exception class names that should be ignored (for example BaseException, \\NS1\\Exception, \\*\\Exception or \\**\\Exception*)", - "items": { - "type": "string" - } - }, - "skipFiles": { - "type": "array", - "description": "An array of glob patterns to skip when debugging. Star patterns and negations are allowed.", - "items": { - "type": "string" - } - }, - "skipEntryPaths": { - "type": "array", - "description": "An array of glob patterns to immediately detach from and ignore for debugging if the entry script matches", - "items": { - "type": "string" - } - }, - "maxConnections": { - "type": "integer", - "description": "Accept only this number of parallel debugging sessions. Additional connections will be dropped.", - "default": 1 - }, - "proxy": { - "type": "object", - "description": "DBGp Proxy settings", - "properties": { - "enable": { - "type": "boolean", - "description": "To enable proxy registration", - "default": false - }, - "host": { - "type": "string", - "description": "The address of the proxy. Supports host name, IP address, or Unix domain socket.", - "default": "127.0.0.1" - }, - "port": { - "type": "integer", - "description": "The port where the adapter will register with the proxy", - "default": 9001 - }, - "key": { - "type": "string", - "description": "A unique key that allows the proxy to match requests to your editor", - "default": "vsc" - }, - "timeout": { - "type": "integer", - "description": "The number of milliseconds to wait before giving up on the connection to proxy", - "default": 3000 - }, - "allowMultipleSessions": { - "type": "boolean", - "description": "If the proxy should forward multiple sessions/connections at the same time or not", - "default": true - } - } - }, - "xdebugSettings": { - "type": "object", - "description": "Allows you to override Xdebug's remote debugging settings to fine tune Xdebug to your needs", - "properties": { - "max_children": { - "type": "integer", - "description": "Max number of array or object children to initially retrieve" - }, - "max_data": { - "type": "integer", - "description": "Max amount of variable data to initially retrieve" - }, - "max_depth": { - "type": "integer", - "description": "Maximum depth that the debugger engine may return when sending arrays, hashes or object structures to the IDE" - }, - "show_hidden": { - "type": "integer", - "description": "Whether to show detailed internal information on properties (e.g. private members of classes). Zero means hidden members are not shown.", - "enum": [0, 1] - }, - "breakpoint_include_return_value": { - "type": "boolean", - "description": "Determines whether to enable an additional \"return from function\" debugging step, allowing inspection of the return value when a function call returns" - } - } - }, - "xdebugCloudToken": { - "type": "string", - "description": "Instead of listening locally, open a connection and register with Xdebug Cloud and accept debugging sessions on that connection" - }, - "stream": { - "type": "object", - "description": "Allows to influence DBGp streams. Xdebug only supports stdout", - "properties": { - "stdout": { - "type": "integer", - "description": "Redirect stdout stream: 0 (disable), 1 (copy), 2 (redirect)", - "enum": [0, 1, 2], - "default": 0 - } - } - } - }, - "required": ["request", "program"] - }) - } - - fn name(&self) -> DebugAdapterName { - DebugAdapterName(Self::ADAPTER_NAME.into()) - } - - fn adapter_language_name(&self) -> Option { - Some(SharedString::new_static("PHP").into()) - } - - async fn request_kind( - &self, - _: &serde_json::Value, - ) -> Result { - Ok(StartDebuggingRequestArgumentsRequest::Launch) - } - - async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result { - let obj = match &zed_scenario.request { - dap::DebugRequest::Attach(_) => { - bail!("Php adapter doesn't support attaching") - } - dap::DebugRequest::Launch(launch_config) => json!({ - "program": launch_config.program, - "cwd": launch_config.cwd, - "args": launch_config.args, - "env": launch_config.env_json(), - "stopOnEntry": zed_scenario.stop_on_entry.unwrap_or_default(), - }), - }; - - Ok(DebugScenario { - adapter: zed_scenario.adapter, - label: zed_scenario.label, - build: None, - config: obj, - tcp_connection: None, - }) - } - - async fn get_binary( - &self, - delegate: &Arc, - task_definition: &DebugTaskDefinition, - user_installed_path: Option, - user_args: Option>, - cx: &mut AsyncApp, - ) -> Result { - if self.checked.set(()).is_ok() { - delegate.output_to_console(format!("Checking latest version of {}...", self.name())); - if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() { - adapters::download_adapter_from_github( - self.name(), - version, - adapters::DownloadedFileType::Vsix, - delegate.as_ref(), - ) - .await?; - } - } - - self.get_installed_binary( - delegate, - &task_definition, - user_installed_path, - user_args, - cx, - ) - .await - } -} diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 43d1246d0c8ff1e2580d50b37f02020dc6804c61..dc3d15e124578e183ba5ed09b80aee7d6dda54c8 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -660,6 +660,15 @@ impl DebugAdapter for PythonDebugAdapter { self.get_installed_binary(delegate, &config, None, user_args, toolchain, false) .await } + + fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option { + let label = args + .configuration + .get("name")? + .as_str() + .filter(|label| !label.is_empty())?; + Some(label.to_owned()) + } } async fn fetch_latest_adapter_version_from_github( diff --git a/crates/debug_adapter_extension/src/extension_locator_adapter.rs b/crates/debug_adapter_extension/src/extension_locator_adapter.rs index 54c03b1eafa1cda8495c29f419f1588c570d78c3..55094ea7de02385ad3a5a75ea8ac0042c50a8600 100644 --- a/crates/debug_adapter_extension/src/extension_locator_adapter.rs +++ b/crates/debug_adapter_extension/src/extension_locator_adapter.rs @@ -44,7 +44,9 @@ impl DapLocator for ExtensionLocatorAdapter { .flatten() } - async fn run(&self, _build_config: SpawnInTerminal) -> Result { - Err(anyhow::anyhow!("Not implemented")) + async fn run(&self, build_config: SpawnInTerminal) -> Result { + self.extension + .run_dap_locator(self.locator_name.as_ref().to_owned(), build_config) + .await } } diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 532107f63302e9e057d2f90c28a2b32bcd0622d7..b806381d251c6595a5dd12022dc3d1df8b71739f 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -32,12 +32,19 @@ use workspace::{ ui::{Button, Clickable, ContextMenu, Label, LabelCommon, PopoverMenu, h_flex}, }; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum View { + AdapterLogs, + RpcMessages, + InitializationSequence, +} + struct DapLogView { editor: Entity, focus_handle: FocusHandle, log_store: Entity, editor_subscriptions: Vec, - current_view: Option<(SessionId, LogKind)>, + current_view: Option<(SessionId, View)>, project: Entity, _subscriptions: Vec, } @@ -77,6 +84,7 @@ struct DebugAdapterState { id: SessionId, log_messages: VecDeque, rpc_messages: RpcMessages, + session_label: SharedString, adapter_name: DebugAdapterName, has_adapter_logs: bool, is_terminated: bool, @@ -121,12 +129,18 @@ impl MessageKind { } impl DebugAdapterState { - fn new(id: SessionId, adapter_name: DebugAdapterName, has_adapter_logs: bool) -> Self { + fn new( + id: SessionId, + adapter_name: DebugAdapterName, + session_label: SharedString, + has_adapter_logs: bool, + ) -> Self { Self { id, log_messages: VecDeque::new(), rpc_messages: RpcMessages::new(), adapter_name, + session_label, has_adapter_logs, is_terminated: false, } @@ -371,18 +385,22 @@ impl LogStore { return None; }; - let (adapter_name, has_adapter_logs) = session.read_with(cx, |session, _| { - ( - session.adapter(), - session - .adapter_client() - .map_or(false, |client| client.has_adapter_logs()), - ) - }); + let (adapter_name, session_label, has_adapter_logs) = + session.read_with(cx, |session, _| { + ( + session.adapter(), + session.label(), + session + .adapter_client() + .map_or(false, |client| client.has_adapter_logs()), + ) + }); state.insert(DebugAdapterState::new( id.session_id, adapter_name, + session_label + .unwrap_or_else(|| format!("Session {} (child)", id.session_id.0).into()), has_adapter_logs, )); @@ -506,12 +524,13 @@ impl Render for DapLogToolbarItemView { current_client .map(|sub_item| { Cow::Owned(format!( - "{} ({}) - {}", + "{} - {} - {}", sub_item.adapter_name, - sub_item.session_id.0, + sub_item.session_label, match sub_item.selected_entry { - LogKind::Adapter => ADAPTER_LOGS, - LogKind::Rpc => RPC_MESSAGES, + View::AdapterLogs => ADAPTER_LOGS, + View::RpcMessages => RPC_MESSAGES, + View::InitializationSequence => INITIALIZATION_SEQUENCE, } )) }) @@ -529,8 +548,8 @@ impl Render for DapLogToolbarItemView { .pl_2() .child( Label::new(format!( - "{}. {}", - row.session_id.0, row.adapter_name, + "{} - {}", + row.adapter_name, row.session_label )) .color(workspace::ui::Color::Muted), ) @@ -669,9 +688,16 @@ impl DapLogView { let events_subscriptions = cx.subscribe(&log_store, |log_view, _, event, cx| match event { Event::NewLogEntry { id, entry, kind } => { - if log_view.current_view == Some((id.session_id, *kind)) - && log_view.project == *id.project - { + let is_current_view = match (log_view.current_view, *kind) { + (Some((i, View::AdapterLogs)), LogKind::Adapter) + | (Some((i, View::RpcMessages)), LogKind::Rpc) + if i == id.session_id => + { + log_view.project == *id.project + } + _ => false, + }; + if is_current_view { log_view.editor.update(cx, |editor, cx| { editor.set_read_only(false); let last_point = editor.buffer().read(cx).len(cx); @@ -768,10 +794,11 @@ impl DapLogView { .map(|state| DapMenuItem { session_id: state.id, adapter_name: state.adapter_name.clone(), + session_label: state.session_label.clone(), has_adapter_logs: state.has_adapter_logs, selected_entry: self .current_view - .map_or(LogKind::Adapter, |(_, kind)| kind), + .map_or(View::AdapterLogs, |(_, kind)| kind), }) .collect::>() }) @@ -789,7 +816,7 @@ impl DapLogView { .map(|state| log_contents(state.iter().cloned())) }); if let Some(rpc_log) = rpc_log { - self.current_view = Some((id.session_id, LogKind::Rpc)); + self.current_view = Some((id.session_id, View::RpcMessages)); let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); let language = self.project.read(cx).languages().language_for_name("JSON"); editor @@ -830,7 +857,7 @@ impl DapLogView { .map(|state| log_contents(state.iter().cloned())) }); if let Some(message_log) = message_log { - self.current_view = Some((id.session_id, LogKind::Adapter)); + self.current_view = Some((id.session_id, View::AdapterLogs)); let (editor, editor_subscriptions) = Self::editor_for_logs(message_log, window, cx); editor .read(cx) @@ -859,7 +886,7 @@ impl DapLogView { .map(|state| log_contents(state.iter().cloned())) }); if let Some(rpc_log) = rpc_log { - self.current_view = Some((id.session_id, LogKind::Rpc)); + self.current_view = Some((id.session_id, View::InitializationSequence)); let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); let language = self.project.read(cx).languages().language_for_name("JSON"); editor @@ -899,11 +926,12 @@ fn log_contents(lines: impl Iterator) -> String { } #[derive(Clone, PartialEq)] -pub(crate) struct DapMenuItem { - pub session_id: SessionId, - pub adapter_name: DebugAdapterName, - pub has_adapter_logs: bool, - pub selected_entry: LogKind, +struct DapMenuItem { + session_id: SessionId, + session_label: SharedString, + adapter_name: DebugAdapterName, + has_adapter_logs: bool, + selected_entry: View, } const ADAPTER_LOGS: &str = "Adapter Logs"; @@ -918,7 +946,13 @@ impl Render for DapLogView { } } -actions!(dev, [OpenDebugAdapterLogs]); +actions!( + dev, + [ + /// Opens the debug adapter protocol logs viewer. + OpenDebugAdapterLogs + ] +); pub fn init(cx: &mut App) { let log_store = cx.new(|cx| LogStore::new(cx)); diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index ba71e50a0830c7fbab60aa75ba14bb63d58bac07..ebb135c1d9fc56e21b40bd0a4f9850d72286d866 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -16,13 +16,13 @@ doctest = false test-support = [ "dap/test-support", "dap_adapters/test-support", + "debugger_tools/test-support", "editor/test-support", "gpui/test-support", "project/test-support", "util/test-support", "workspace/test-support", "unindent", - "debugger_tools" ] [dependencies] @@ -40,11 +40,15 @@ file_icons.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +hex.workspace = true +indoc.workspace = true itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true +notifications.workspace = true parking_lot.workspace = true +parse_int.workspace = true paths.workspace = true picker.workspace = true pretty_assertions.workspace = true @@ -60,6 +64,7 @@ task.workspace = true tasks_ui.workspace = true telemetry.workspace = true terminal_view.workspace = true +text.workspace = true theme.workspace = true tree-sitter.workspace = true tree-sitter-json.workspace = true @@ -67,7 +72,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true workspace-hack.workspace = true -debugger_tools = { workspace = true, optional = true } +debugger_tools.workspace = true unindent = { workspace = true, optional = true } zed_actions.workspace = true diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index aa4ca9e868a508ca646b005bb08944d282d7bc37..662a98c82075cd6e936988959c855eadb5138092 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -206,7 +206,7 @@ impl PickerDelegate for AttachModalDelegate { }) } - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { let candidate = self .matches .get(self.selected_index()) @@ -229,30 +229,44 @@ impl PickerDelegate for AttachModalDelegate { } } + let workspace = self.workspace.clone(); + let Some(panel) = workspace + .update(cx, |workspace, cx| workspace.panel::(cx)) + .ok() + .flatten() + else { + return; + }; + + if secondary { + // let Some(id) = worktree_id else { return }; + // cx.spawn_in(window, async move |_, cx| { + // panel + // .update_in(cx, |debug_panel, window, cx| { + // debug_panel.save_scenario(&debug_scenario, id, window, cx) + // })? + // .await?; + // anyhow::Ok(()) + // }) + // .detach_and_log_err(cx); + } let Some(adapter) = cx.read_global::(|registry, _| { registry.adapter(&self.definition.adapter) }) else { return; }; - let workspace = self.workspace.clone(); let definition = self.definition.clone(); cx.spawn_in(window, async move |this, cx| { let Ok(scenario) = adapter.config_from_zed_format(definition).await else { return; }; - let panel = workspace - .update(cx, |workspace, cx| workspace.panel::(cx)) - .ok() - .flatten(); - if let Some(panel) = panel { - panel - .update_in(cx, |panel, window, cx| { - panel.start_session(scenario, Default::default(), None, None, window, cx); - }) - .ok(); - } + panel + .update_in(cx, |panel, window, cx| { + panel.start_session(scenario, Default::default(), None, None, window, cx); + }) + .ok(); this.update(cx, |_, cx| { cx.emit(DismissEvent); }) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index d03e8c5225f04fae6b12d220a78a6806ebeaf6aa..bf5f31391885edf89beea3e8648df13f68258a77 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -2,6 +2,7 @@ use crate::persistence::DebuggerPaneItem; use crate::session::DebugSession; use crate::session::running::RunningState; use crate::session::running::breakpoint_list::BreakpointList; + use crate::{ ClearAllBreakpoints, Continue, CopyDebugAdapterArguments, Detach, FocusBreakpointList, FocusConsole, FocusFrames, FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables, @@ -9,6 +10,7 @@ use crate::{ ToggleExpandItem, ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal, }; use anyhow::{Context as _, Result, anyhow}; +use collections::IndexMap; use dap::adapters::DebugAdapterName; use dap::debugger_settings::DebugPanelDockPosition; use dap::{ @@ -16,16 +18,18 @@ use dap::{ client::SessionId, debugger_settings::DebuggerSettings, }; use dap::{DapRegistry, StartDebuggingRequestArguments}; +use editor::Editor; use gpui::{ Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, WeakEntity, anchored, deferred, }; +use text::ToPoint as _; use itertools::Itertools as _; use language::Buffer; -use project::debugger::session::{Session, SessionStateEvent}; -use project::{DebugScenarioContext, Fs, ProjectPath, WorktreeId}; +use project::debugger::session::{Session, SessionQuirks, SessionState, SessionStateEvent}; +use project::{DebugScenarioContext, Fs, ProjectPath, TaskSourceKind, WorktreeId}; use project::{Project, debugger::session::ThreadStatus}; use rpc::proto::{self}; use settings::Settings; @@ -33,10 +37,11 @@ use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*}; -use util::maybe; +use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; +use workspace::item::SaveOptions; use workspace::{ - Pane, Workspace, + Item, Pane, Workspace, dock::{DockPosition, Panel, PanelEvent}, }; use zed_actions::ToggleFocus; @@ -60,13 +65,14 @@ pub enum DebugPanelEvent { pub struct DebugPanel { size: Pixels, - sessions: Vec>, active_session: Option>, project: Entity, workspace: WeakEntity, focus_handle: FocusHandle, context_menu: Option<(Entity, Point, Subscription)>, debug_scenario_scheduled_last: bool, + pub(crate) sessions_with_children: + IndexMap, Vec>>, pub(crate) thread_picker_menu_handle: PopoverMenuHandle, pub(crate) session_picker_menu_handle: PopoverMenuHandle, fs: Arc, @@ -97,7 +103,7 @@ impl DebugPanel { Self { size: px(300.), - sessions: vec![], + sessions_with_children: Default::default(), active_session: None, focus_handle, breakpoint_list: BreakpointList::new( @@ -135,8 +141,9 @@ impl DebugPanel { }); } - pub(crate) fn sessions(&self) -> Vec> { - self.sessions.clone() + #[cfg(test)] + pub(crate) fn sessions(&self) -> impl Iterator> { + self.sessions_with_children.keys().cloned() } pub fn active_session(&self) -> Option> { @@ -182,12 +189,20 @@ impl DebugPanel { cx: &mut Context, ) { let dap_store = self.project.read(cx).dap_store(); + let Some(adapter) = DapRegistry::global(cx).adapter(&scenario.adapter) else { + return; + }; + let quirks = SessionQuirks { + compact: adapter.compact_child_session(), + prefer_thread_name: adapter.prefer_thread_name(), + }; let session = dap_store.update(cx, |dap_store, cx| { dap_store.new_session( - scenario.label.clone(), + Some(scenario.label.clone()), DebugAdapterName(scenario.adapter.clone()), task_context.clone(), None, + quirks, cx, ) }); @@ -264,22 +279,34 @@ impl DebugPanel { } }); - cx.spawn(async move |_, cx| { - if let Err(error) = task.await { - log::error!("{error}"); - session - .update(cx, |session, cx| { - session - .console_output(cx) - .unbounded_send(format!("error: {}", error)) - .ok(); - session.shutdown(cx) - })? - .await; + let boot_task = cx.spawn({ + let session = session.clone(); + + async move |_, cx| { + if let Err(error) = task.await { + log::error!("{error}"); + session + .update(cx, |session, cx| { + session + .console_output(cx) + .unbounded_send(format!("error: {}", error)) + .ok(); + session.shutdown(cx) + })? + .await; + } + anyhow::Ok(()) } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + }); + + session.update(cx, |session, _| match &mut session.mode { + SessionState::Building(state_task) => { + *state_task = Some(boot_task); + } + SessionState::Running(_) => { + debug_panic!("Session state should be in building because we are just starting it"); + } + }); } pub(crate) fn rerun_last_session( @@ -360,17 +387,24 @@ impl DebugPanel { }; let dap_store_handle = self.project.read(cx).dap_store().clone(); - let label = curr_session.read(cx).label().clone(); + let label = curr_session.read(cx).label(); + let quirks = curr_session.read(cx).quirks(); let adapter = curr_session.read(cx).adapter().clone(); let binary = curr_session.read(cx).binary().cloned().unwrap(); - let task = curr_session.update(cx, |session, cx| session.shutdown(cx)); let task_context = curr_session.read(cx).task_context().clone(); + let curr_session_id = curr_session.read(cx).session_id(); + self.sessions_with_children + .retain(|session, _| session.read(cx).session_id(cx) != curr_session_id); + let task = dap_store_handle.update(cx, |dap_store, cx| { + dap_store.shutdown_session(curr_session_id, cx) + }); + cx.spawn_in(window, async move |this, cx| { - task.await; + task.await.log_err(); let (session, task) = dap_store_handle.update(cx, |dap_store, cx| { - let session = dap_store.new_session(label, adapter, task_context, None, cx); + let session = dap_store.new_session(label, adapter, task_context, None, quirks, cx); let task = session.update(cx, |session, cx| { session.boot(binary, worktree, dap_store_handle.downgrade(), cx) @@ -416,6 +450,7 @@ impl DebugPanel { let dap_store_handle = self.project.read(cx).dap_store().clone(); let label = self.label_for_child_session(&parent_session, request, cx); let adapter = parent_session.read(cx).adapter().clone(); + 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"); return; @@ -429,6 +464,7 @@ impl DebugPanel { adapter, task_context, Some(parent_session.clone()), + quirks, cx, ); @@ -454,8 +490,8 @@ impl DebugPanel { cx: &mut Context, ) { let Some(session) = self - .sessions - .iter() + .sessions_with_children + .keys() .find(|other| entity_id == other.entity_id()) .cloned() else { @@ -489,15 +525,14 @@ impl DebugPanel { } session.update(cx, |session, cx| session.shutdown(cx)).ok(); this.update(cx, |this, cx| { - this.sessions.retain(|other| entity_id != other.entity_id()); - + this.retain_sessions(|other| entity_id != other.entity_id()); if let Some(active_session_id) = this .active_session .as_ref() .map(|session| session.entity_id()) { if active_session_id == entity_id { - this.active_session = this.sessions.first().cloned(); + this.active_session = this.sessions_with_children.keys().next().cloned(); } } cx.notify() @@ -613,6 +648,14 @@ impl DebugPanel { .on_click(move |_, _, cx| cx.open_url("https://zed.dev/docs/debugger")) .tooltip(Tooltip::text("Open Documentation")) }; + let logs_button = || { + IconButton::new("debug-open-logs", IconName::ScrollText) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + window.dispatch_action(debugger_tools::OpenDebugAdapterLogs.boxed_clone(), cx) + }) + .tooltip(Tooltip::text("Open Debug Adapter Logs")) + }; Some( div.border_b_1() @@ -796,13 +839,24 @@ impl DebugPanel { .on_click(window.listener_for( &running_state, |this, _, _window, cx| { - this.stop_thread(cx); + if this.session().read(cx).is_building() { + this.session().update(cx, |session, cx| { + session.shutdown(cx).detach() + }); + } else { + this.stop_thread(cx); + } + }, + )) + .disabled(active_session.as_ref().is_none_or( + |session| { + session + .read(cx) + .session(cx) + .read(cx) + .is_terminated() }, )) - .disabled( - thread_status != ThreadStatus::Stopped - && thread_status != ThreadStatus::Running, - ) .tooltip({ let focus_handle = focus_handle.clone(); let label = if capabilities @@ -864,6 +918,7 @@ impl DebugPanel { .justify_around() .when(is_side, |this| { this.child(new_session_button()) + .child(logs_button()) .child(documentation_button()) }), ) @@ -913,6 +968,7 @@ impl DebugPanel { )) .when(!is_side, |this| { this.child(new_session_button()) + .child(logs_button()) .child(documentation_button()) }), ), @@ -957,8 +1013,8 @@ impl DebugPanel { cx: &mut Context, ) { if let Some(session) = self - .sessions - .iter() + .sessions_with_children + .keys() .find(|session| session.read(cx).session_id(cx) == session_id) { self.activate_session(session.clone(), window, cx); @@ -971,7 +1027,7 @@ impl DebugPanel { window: &mut Window, cx: &mut Context, ) { - debug_assert!(self.sessions.contains(&session_item)); + debug_assert!(self.sessions_with_children.contains_key(&session_item)); session_item.focus_handle(cx).focus(window); session_item.update(cx, |this, cx| { this.running_state().update(cx, |this, cx| { @@ -982,13 +1038,90 @@ impl DebugPanel { cx.notify(); } + pub(crate) fn go_to_scenario_definition( + &self, + kind: TaskSourceKind, + scenario: DebugScenario, + worktree_id: WorktreeId, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Ok(())); + }; + let project_path = match kind { + TaskSourceKind::AbsPath { abs_path, .. } => { + let Some(project_path) = workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("no abs path"))); + }; + + project_path + } + TaskSourceKind::Worktree { + id, + directory_in_worktree: dir, + .. + } => { + let relative_path = if dir.ends_with(".vscode") { + dir.join("launch.json") + } else { + dir.join("debug.json") + }; + ProjectPath { + worktree_id: id, + path: Arc::from(relative_path), + } + } + _ => return self.save_scenario(scenario, worktree_id, window, cx), + }; + + let editor = workspace.update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) + }); + cx.spawn_in(window, async move |_, cx| { + let editor = editor.await?; + let editor = cx + .update(|_, cx| editor.act_as::(cx))? + .context("expected editor")?; + + // unfortunately debug tasks don't have an easy way to globally + // identify them. to jump to the one that you just created or an + // old one that you're choosing to edit we use a heuristic of searching for a line with `label: ` from the end rather than the start so we bias towards more renctly + editor.update_in(cx, |editor, window, cx| { + let row = editor.text(cx).lines().enumerate().find_map(|(row, text)| { + if text.contains(scenario.label.as_ref()) && text.contains("\"label\": ") { + Some(row) + } else { + None + } + }); + if let Some(row) = row { + editor.go_to_singleton_buffer_point( + text::Point::new(row as u32, 4), + window, + cx, + ); + } + })?; + + Ok(()) + }) + } + pub(crate) fn save_scenario( &self, - scenario: &DebugScenario, + scenario: DebugScenario, worktree_id: WorktreeId, window: &mut Window, - cx: &mut App, - ) -> Task> { + cx: &mut Context, + ) -> Task> { + let this = cx.weak_entity(); + let project = self.project.clone(); self.workspace .update(cx, |workspace, cx| { let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else { @@ -1021,47 +1154,7 @@ impl DebugPanel { ) .await?; } - - let mut content = fs.load(path).await?; - let new_scenario = serde_json_lenient::to_string_pretty(&serialized_scenario)? - .lines() - .map(|l| format!(" {l}")) - .join("\n"); - - static ARRAY_QUERY: LazyLock = LazyLock::new(|| { - Query::new( - &tree_sitter_json::LANGUAGE.into(), - "(document (array (object) @object))", // TODO: use "." anchor to only match last object - ) - .expect("Failed to create ARRAY_QUERY") - }); - - let mut parser = tree_sitter::Parser::new(); - parser - .set_language(&tree_sitter_json::LANGUAGE.into()) - .unwrap(); - let mut cursor = tree_sitter::QueryCursor::new(); - let syntax_tree = parser.parse(&content, None).unwrap(); - let mut matches = - cursor.matches(&ARRAY_QUERY, syntax_tree.root_node(), content.as_bytes()); - - // we don't have `.last()` since it's a lending iterator, so loop over - // the whole thing to find the last one - let mut last_offset = None; - while let Some(mat) = matches.next() { - if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end) { - last_offset = Some(pos) - } - } - - if let Some(pos) = last_offset { - content.insert_str(pos, &new_scenario); - content.insert_str(pos, ",\n"); - } - - fs.write(path, content.as_bytes()).await?; - - workspace.update(cx, |workspace, cx| { + let project_path = workspace.update(cx, |workspace, cx| { workspace .project() .read(cx) @@ -1069,12 +1162,113 @@ impl DebugPanel { .context( "Couldn't get project path for .zed/debug.json in active worktree", ) - })? + })??; + + let editor = this + .update_in(cx, |this, window, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) + }) + })?? + .await?; + let editor = cx + .update(|_, cx| editor.act_as::(cx))? + .context("expected editor")?; + + let new_scenario = serde_json_lenient::to_string_pretty(&serialized_scenario)? + .lines() + .map(|l| format!(" {l}")) + .join("\n"); + + editor + .update_in(cx, |editor, window, cx| { + Self::insert_task_into_editor(editor, new_scenario, project, window, cx) + })?? + .await }) }) .unwrap_or_else(|err| Task::ready(Err(err))) } + pub fn insert_task_into_editor( + editor: &mut Editor, + new_scenario: String, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Result>> { + static LAST_ITEM_QUERY: LazyLock = LazyLock::new(|| { + Query::new( + &tree_sitter_json::LANGUAGE.into(), + "(document (array (object) @object))", // TODO: use "." anchor to only match last object + ) + .expect("Failed to create LAST_ITEM_QUERY") + }); + static EMPTY_ARRAY_QUERY: LazyLock = LazyLock::new(|| { + Query::new( + &tree_sitter_json::LANGUAGE.into(), + "(document (array) @array)", + ) + .expect("Failed to create EMPTY_ARRAY_QUERY") + }); + + let content = editor.text(cx); + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&tree_sitter_json::LANGUAGE.into())?; + let mut cursor = tree_sitter::QueryCursor::new(); + let syntax_tree = parser + .parse(&content, None) + .context("could not parse debug.json")?; + let mut matches = cursor.matches( + &LAST_ITEM_QUERY, + syntax_tree.root_node(), + content.as_bytes(), + ); + + let mut last_offset = None; + while let Some(mat) = matches.next() { + if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end) { + last_offset = Some(pos) + } + } + let mut edits = Vec::new(); + let mut cursor_position = 0; + + if let Some(pos) = last_offset { + edits.push((pos..pos, format!(",\n{new_scenario}"))); + cursor_position = pos + ",\n ".len(); + } else { + let mut matches = cursor.matches( + &EMPTY_ARRAY_QUERY, + syntax_tree.root_node(), + content.as_bytes(), + ); + + if let Some(mat) = matches.next() { + if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end - 1) { + edits.push((pos..pos, format!("\n{new_scenario}\n"))); + cursor_position = pos + "\n ".len(); + } + } else { + edits.push((0..0, format!("[\n{}\n]", new_scenario))); + cursor_position = "[\n ".len(); + } + } + editor.transact(window, cx, |editor, window, cx| { + editor.edit(edits, cx); + let snapshot = editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .snapshot(); + let point = cursor_position.to_point(&snapshot); + editor.go_to_singleton_buffer_point(point, window, cx); + }); + Ok(editor.save(SaveOptions::default(), project, window, cx)) + } + pub(crate) fn toggle_thread_picker(&mut self, window: &mut Window, cx: &mut Context) { self.thread_picker_menu_handle.toggle(window, cx); } @@ -1104,18 +1298,27 @@ impl DebugPanel { parent_session: &Entity, request: &StartDebuggingRequestArguments, cx: &mut Context<'_, Self>, - ) -> SharedString { + ) -> 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 label.into(); + return Some(label.into()); } } - let mut label = parent_session.read(cx).label().clone(); - if !label.ends_with("(child)") { - label = format!("{label} (child)").into(); + None + } + + fn retain_sessions(&mut self, keep: impl Fn(&Entity) -> bool) { + self.sessions_with_children + .retain(|session, _| keep(session)); + for children in self.sessions_with_children.values_mut() { + children.retain(|child| { + let Some(child) = child.upgrade() else { + return false; + }; + keep(&child) + }); } - label } } @@ -1145,11 +1348,11 @@ async fn register_session_inner( let serialized_layout = persistence::get_serialized_layout(adapter_name).await; let debug_session = this.update_in(cx, |this, window, cx| { let parent_session = this - .sessions - .iter() + .sessions_with_children + .keys() .find(|p| Some(p.read(cx).session_id(cx)) == session.read(cx).parent_id(cx)) .cloned(); - this.sessions.retain(|session| { + this.retain_sessions(|session| { !session .read(cx) .running_state() @@ -1180,13 +1383,23 @@ async fn register_session_inner( ) .detach(); let insert_position = this - .sessions - .iter() + .sessions_with_children + .keys() .position(|session| Some(session) == parent_session.as_ref()) .map(|position| position + 1) - .unwrap_or(this.sessions.len()); + .unwrap_or(this.sessions_with_children.len()); // Maintain topological sort order of sessions - this.sessions.insert(insert_position, debug_session.clone()); + let (_, old) = this.sessions_with_children.insert_before( + insert_position, + debug_session.clone(), + Default::default(), + ); + debug_assert!(old.is_none()); + if let Some(parent_session) = parent_session { + this.sessions_with_children + .entry(parent_session) + .and_modify(|children| children.push(debug_session.downgrade())); + } debug_session })?; @@ -1226,7 +1439,7 @@ impl Panel for DebugPanel { cx: &mut Context, ) { if position.axis() != self.position(window, cx).axis() { - self.sessions.iter().for_each(|session_item| { + self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() .update(cx, |state, _| state.invert_axies()) @@ -1298,9 +1511,7 @@ impl Panel for DebugPanel { impl Render for DebugPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_sessions = self.sessions.len() > 0; let this = cx.weak_entity(); - debug_assert_eq!(has_sessions, self.active_session.is_some()); if self .active_session @@ -1487,8 +1698,8 @@ impl Render for DebugPanel { })) }) .map(|this| { - if has_sessions { - this.children(self.active_session.clone()) + if let Some(active_session) = self.active_session.clone() { + this.child(active_session) } else { let docked_to_bottom = self.position(window, cx) == DockPosition::Bottom; let welcome_experience = v_flex() @@ -1594,6 +1805,7 @@ impl Render for DebugPanel { .child(breakpoint_list) .child(Divider::vertical()) .child(welcome_experience) + .child(Divider::vertical()) } else { this.items_end() .child(welcome_experience) diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 71b3ce1a31a9722d384379f6535617bc0c94f56a..c932f1b600effa424db6b995d8128dca5c29594f 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -32,36 +32,69 @@ pub mod tests; actions!( debugger, [ + /// Starts a new debugging session. Start, + /// Continues execution until the next breakpoint. Continue, + /// Detaches the debugger from the running process. Detach, + /// Pauses the currently running program. Pause, + /// Restarts the current debugging session. Restart, + /// Reruns the current debugging session with the same configuration. RerunSession, + /// Steps into the next function call. StepInto, + /// Steps over the current line. StepOver, + /// Steps out of the current function. StepOut, + /// Steps back to the previous statement. StepBack, + /// Stops the debugging session. Stop, + /// Toggles whether to ignore all breakpoints. ToggleIgnoreBreakpoints, + /// Clears all breakpoints in the project. ClearAllBreakpoints, + /// Focuses on the debugger console panel. FocusConsole, + /// Focuses on the variables panel. FocusVariables, + /// Focuses on the breakpoint list panel. FocusBreakpointList, + /// Focuses on the call stack frames panel. FocusFrames, + /// Focuses on the loaded modules panel. FocusModules, + /// Focuses on the loaded sources panel. FocusLoadedSources, + /// Focuses on the terminal panel. FocusTerminal, + /// Shows the stack trace for the current thread. ShowStackTrace, + /// Toggles the thread picker dropdown. ToggleThreadPicker, + /// Toggles the session picker dropdown. ToggleSessionPicker, + /// Reruns the last debugging session. #[action(deprecated_aliases = ["debugger::RerunLastSession"])] Rerun, + /// Toggles expansion of the selected item in the debugger UI. ToggleExpandItem, + /// Set a data breakpoint on the selected variable or memory region. + ToggleDataBreakpoint, ] ); -actions!(dev, [CopyDebugAdapterArguments]); +actions!( + dev, + [ + /// Copies debug adapter launch arguments to clipboard. + CopyDebugAdapterArguments + ] +); pub fn init(cx: &mut App) { DebuggerSettings::register(cx); diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index f93aceae094db9a75b9550021c97bb9723ad6811..dca15eb0527cfc78bd137889a1910e6b32abf98c 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -1,16 +1,82 @@ -use std::time::Duration; +use std::{rc::Rc, time::Duration}; use collections::HashMap; -use gpui::{Animation, AnimationExt as _, Entity, Transformation, percentage}; +use gpui::{Animation, AnimationExt as _, Entity, Transformation, WeakEntity, percentage}; use project::debugger::session::{ThreadId, ThreadStatus}; use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; -use util::truncate_and_trailoff; +use util::{maybe, truncate_and_trailoff}; use crate::{ debugger_panel::DebugPanel, session::{DebugSession, running::RunningState}, }; +struct SessionListEntry { + ancestors: Vec>, + leaf: Entity, +} + +impl SessionListEntry { + pub(crate) fn label_element(&self, depth: usize, cx: &mut App) -> AnyElement { + const MAX_LABEL_CHARS: usize = 150; + + let mut label = String::new(); + for ancestor in &self.ancestors { + label.push_str(&ancestor.update(cx, |ancestor, cx| { + ancestor.label(cx).unwrap_or("(child)".into()) + })); + label.push_str(" » "); + } + label.push_str( + &self + .leaf + .update(cx, |leaf, cx| leaf.label(cx).unwrap_or("(child)".into())), + ); + let label = truncate_and_trailoff(&label, MAX_LABEL_CHARS); + + let is_terminated = self + .leaf + .read(cx) + .running_state + .read(cx) + .session() + .read(cx) + .is_terminated(); + let icon = { + if is_terminated { + Some(Indicator::dot().color(Color::Error)) + } else { + match self + .leaf + .read(cx) + .running_state + .read(cx) + .thread_status(cx) + .unwrap_or_default() + { + project::debugger::session::ThreadStatus::Stopped => { + Some(Indicator::dot().color(Color::Conflict)) + } + _ => Some(Indicator::dot().color(Color::Success)), + } + } + }; + + h_flex() + .id("session-label") + .ml(depth * px(16.0)) + .gap_2() + .when_some(icon, |this, indicator| this.child(indicator)) + .justify_between() + .child( + Label::new(label) + .size(LabelSize::Small) + .when(is_terminated, |this| this.strikethrough()), + ) + .into_any_element() + } +} + impl DebugPanel { fn dropdown_label(label: impl Into) -> Label { const MAX_LABEL_CHARS: usize = 50; @@ -25,145 +91,205 @@ impl DebugPanel { window: &mut Window, cx: &mut Context, ) -> Option { - if let Some(running_state) = running_state { - let sessions = self.sessions().clone(); - let weak = cx.weak_entity(); - let running_state = running_state.read(cx); - let label = if let Some(active_session) = active_session.clone() { - active_session.read(cx).session(cx).read(cx).label() - } else { - SharedString::new_static("Unknown Session") - }; + let running_state = running_state?; + + let mut session_entries = Vec::with_capacity(self.sessions_with_children.len() * 3); + let mut sessions_with_children = self.sessions_with_children.iter().peekable(); - let is_terminated = running_state.session().read(cx).is_terminated(); - let is_started = active_session - .is_some_and(|session| session.read(cx).session(cx).read(cx).is_started()); - - let session_state_indicator = if is_terminated { - Indicator::dot().color(Color::Error).into_any_element() - } else if !is_started { - 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))), - ) - .into_any_element() + while let Some((root, children)) = sessions_with_children.next() { + let root_entry = if let Ok([single_child]) = <&[_; 1]>::try_from(children.as_slice()) + && let Some(single_child) = single_child.upgrade() + && single_child.read(cx).quirks.compact + { + sessions_with_children.next(); + SessionListEntry { + leaf: single_child.clone(), + ancestors: vec![root.clone()], + } } else { - match running_state.thread_status(cx).unwrap_or_default() { - ThreadStatus::Stopped => { - Indicator::dot().color(Color::Conflict).into_any_element() - } - _ => Indicator::dot().color(Color::Success).into_any_element(), + SessionListEntry { + leaf: root.clone(), + ancestors: Vec::new(), } }; + 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 trigger = h_flex() - .gap_2() - .child(session_state_indicator) - .justify_between() - .child( - DebugPanel::dropdown_label(label) - .when(is_terminated, |this| this.strikethrough()), + let weak = cx.weak_entity(); + let trigger_label = if let Some(active_session) = active_session.clone() { + active_session.update(cx, |active_session, cx| { + active_session.label(cx).unwrap_or("(child)".into()) + }) + } else { + SharedString::new_static("Unknown Session") + }; + let running_state = running_state.read(cx); + + let is_terminated = running_state.session().read(cx).is_terminated(); + let is_started = active_session + .is_some_and(|session| session.read(cx).session(cx).read(cx).is_started()); + + let session_state_indicator = if is_terminated { + Indicator::dot().color(Color::Error).into_any_element() + } else if !is_started { + 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))), ) - .into_any_element(); + .into_any_element() + } else { + match running_state.thread_status(cx).unwrap_or_default() { + ThreadStatus::Stopped => Indicator::dot().color(Color::Conflict).into_any_element(), + _ => Indicator::dot().color(Color::Success).into_any_element(), + } + }; - Some( - DropdownMenu::new_with_element( - "debugger-session-list", - trigger, - ContextMenu::build(window, cx, move |mut this, _, cx| { - let context_menu = cx.weak_entity(); - let mut session_depths = HashMap::default(); - for session in sessions.into_iter() { - let weak_session = session.downgrade(); - let weak_session_id = weak_session.entity_id(); - let session_id = session.read(cx).session_id(cx); - let parent_depth = session - .read(cx) - .session(cx) - .read(cx) - .parent_id(cx) - .and_then(|parent_id| session_depths.get(&parent_id).cloned()); - let self_depth = - *session_depths.entry(session_id).or_insert_with(|| { - parent_depth.map(|depth| depth + 1).unwrap_or(0usize) - }); - this = this.custom_entry( - { - let weak = weak.clone(); - let context_menu = context_menu.clone(); - move |_, cx| { - weak_session - .read_with(cx, |session, cx| { - let context_menu = context_menu.clone(); - - let id: SharedString = - format!("debug-session-{}", session_id.0) - .into(); - - h_flex() - .w_full() - .group(id.clone()) - .justify_between() - .child(session.label_element(self_depth, cx)) - .child( - IconButton::new( - "close-debug-session", - IconName::Close, - ) - .visible_on_hover(id.clone()) - .icon_size(IconSize::Small) - .on_click({ - let weak = weak.clone(); - move |_, window, cx| { - weak.update(cx, |panel, cx| { - panel.close_session( - weak_session_id, - window, - cx, - ); - }) - .ok(); - context_menu - .update(cx, |this, cx| { - this.cancel( - &Default::default(), - window, - cx, - ); - }) - .ok(); - } - }), - ) - .into_any_element() - }) - .unwrap_or_else(|_| div().into_any_element()) - } - }, - { - let weak = weak.clone(); - move |window, cx| { - weak.update(cx, |panel, cx| { - panel.activate_session(session.clone(), window, cx); - }) - .ok(); - } - }, - ); + let trigger = h_flex() + .gap_2() + .child(session_state_indicator) + .justify_between() + .child( + DebugPanel::dropdown_label(trigger_label) + .when(is_terminated, |this| this.strikethrough()), + ) + .into_any_element(); + + let menu = DropdownMenu::new_with_element( + "debugger-session-list", + trigger, + ContextMenu::build(window, cx, move |mut this, _, cx| { + let context_menu = cx.weak_entity(); + let mut session_depths = HashMap::default(); + for session_entry in session_entries { + let session_id = session_entry.leaf.read(cx).session_id(cx); + let parent_depth = session_entry + .ancestors + .first() + .unwrap_or(&session_entry.leaf) + .read(cx) + .session(cx) + .read(cx) + .parent_id(cx) + .and_then(|parent_id| session_depths.get(&parent_id).cloned()); + let self_depth = *session_depths + .entry(session_id) + .or_insert_with(|| parent_depth.map(|depth| depth + 1).unwrap_or(0usize)); + this = this.custom_entry( + { + let weak = weak.clone(); + let context_menu = context_menu.clone(); + let ancestors: Rc<[_]> = session_entry + .ancestors + .iter() + .map(|session| session.downgrade()) + .collect(); + let leaf = session_entry.leaf.downgrade(); + move |window, cx| { + Self::render_session_menu_entry( + weak.clone(), + context_menu.clone(), + ancestors.clone(), + leaf.clone(), + self_depth, + window, + cx, + ) + } + }, + { + let weak = weak.clone(); + let leaf = session_entry.leaf.clone(); + move |window, cx| { + weak.update(cx, |panel, cx| { + panel.activate_session(leaf.clone(), window, cx); + }) + .ok(); + } + }, + ); + } + this + }), + ) + .style(DropdownStyle::Ghost) + .handle(self.session_picker_menu_handle.clone()); + + Some(menu) + } + + fn render_session_menu_entry( + weak: WeakEntity, + context_menu: WeakEntity, + ancestors: Rc<[WeakEntity]>, + leaf: WeakEntity, + self_depth: usize, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let Some(session_entry) = maybe!({ + let ancestors = ancestors + .iter() + .map(|ancestor| ancestor.upgrade()) + .collect::>>()?; + let leaf = leaf.upgrade()?; + Some(SessionListEntry { ancestors, leaf }) + }) else { + return div().into_any_element(); + }; + + let id: SharedString = format!( + "debug-session-{}", + session_entry.leaf.read(cx).session_id(cx).0 + ) + .into(); + let session_entity_id = session_entry.leaf.entity_id(); + + h_flex() + .w_full() + .group(id.clone()) + .justify_between() + .child(session_entry.label_element(self_depth, cx)) + .child( + IconButton::new("close-debug-session", IconName::Close) + .visible_on_hover(id.clone()) + .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); + }) + .ok(); + context_menu + .update(cx, |this, cx| { + this.cancel(&Default::default(), window, cx); + }) + .ok(); } - this }), - ) - .style(DropdownStyle::Ghost) - .handle(self.session_picker_menu_handle.clone()), ) - } else { - None - } + .into_any_element() } pub(crate) fn render_thread_dropdown( diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index e857e336775cfc42fd073ba199f855a967656e12..6d7fa244a2e2bfaaaa82f1321d446627e2b0c343 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1,12 +1,10 @@ -use anyhow::bail; +use anyhow::{Context as _, bail}; use collections::{FxHashMap, HashMap}; use language::LanguageRegistry; -use paths::local_debug_file_relative_path; use std::{ borrow::Cow, path::{Path, PathBuf}, sync::Arc, - time::Duration, usize, }; use tasks_ui::{TaskOverrides, TasksModal}; @@ -18,35 +16,27 @@ use editor::{Editor, EditorElement, EditorStyle}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - HighlightStyle, InteractiveText, KeyContext, PromptButton, PromptLevel, Render, StyledText, - Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, + KeyContext, Render, Subscription, Task, TextStyle, WeakEntity, }; use itertools::Itertools as _; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; -use project::{ - DebugScenarioContext, ProjectPath, TaskContexts, TaskSourceKind, task_store::TaskStore, -}; -use settings::{Settings, initial_local_debug_tasks_content}; +use project::{DebugScenarioContext, TaskContexts, TaskSourceKind, task_store::TaskStore}; +use settings::Settings; use task::{DebugScenario, RevealTarget, ZedDebugConfig}; use theme::ThemeSettings; use ui::{ - ActiveTheme, CheckboxWithLabel, Clickable, Context, ContextMenu, Disableable, DropdownMenu, - FluentBuilder, IconWithIndicator, Indicator, IntoElement, KeyBinding, ListItem, - ListItemSpacing, ParentElement, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip, - Window, div, prelude::*, px, relative, rems, + ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context, + ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconName, IconSize, + IconWithIndicator, Indicator, InteractiveElement, IntoElement, KeyBinding, Label, + LabelCommon as _, LabelSize, ListItem, ListItemSpacing, ParentElement, RenderOnce, + SharedString, Styled, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip, Window, div, + h_flex, relative, rems, v_flex, }; use util::ResultExt; -use workspace::{ModalView, Workspace, pane}; +use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane}; use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel}; -#[allow(unused)] -enum SaveScenarioState { - Saving, - Saved((ProjectPath, SharedString)), - Failed(SharedString), -} - pub(super) struct NewProcessModal { workspace: WeakEntity, debug_panel: WeakEntity, @@ -56,7 +46,6 @@ pub(super) struct NewProcessModal { configure_mode: Entity, task_mode: TaskMode, debugger: Option, - save_scenario_state: Option, _subscriptions: [Subscription; 3], } @@ -268,7 +257,6 @@ impl NewProcessModal { mode, debug_panel: debug_panel.downgrade(), workspace: workspace_handle, - save_scenario_state: None, _subscriptions, } }); @@ -420,63 +408,29 @@ impl NewProcessModal { self.debug_picker.read(cx).delegate.task_contexts.clone() } - fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context) { - let task_contents = self.task_contexts(cx); + pub fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context) { + let task_contexts = self.task_contexts(cx); let Some(adapter) = self.debugger.as_ref() else { return; }; let scenario = self.debug_scenario(&adapter, cx); - - self.save_scenario_state = Some(SaveScenarioState::Saving); - cx.spawn_in(window, async move |this, cx| { - let Some((scenario, worktree_id)) = scenario - .await - .zip(task_contents.and_then(|tcx| tcx.worktree())) - else { - this.update(cx, |this, _| { - this.save_scenario_state = Some(SaveScenarioState::Failed( - "Couldn't get scenario or task contents".into(), - )) - }) - .ok(); - return; - }; - - let Some(save_scenario) = this - .update_in(cx, |this, window, cx| { - this.debug_panel - .update(cx, |panel, cx| { - panel.save_scenario(&scenario, worktree_id, window, cx) - }) - .ok() + let scenario = scenario.await.context("no scenario to save")?; + let worktree_id = task_contexts + .context("no task contexts")? + .worktree() + .context("no active worktree")?; + this.update_in(cx, |this, window, cx| { + this.debug_panel.update(cx, |panel, cx| { + panel.save_scenario(scenario, worktree_id, window, cx) }) - .ok() - .flatten() - else { - return; - }; - let res = save_scenario.await; - - this.update(cx, |this, _| match res { - Ok(saved_file) => { - this.save_scenario_state = Some(SaveScenarioState::Saved(( - saved_file, - scenario.label.clone(), - ))) - } - Err(error) => { - this.save_scenario_state = - Some(SaveScenarioState::Failed(error.to_string().into())) - } + })?? + .await?; + this.update_in(cx, |_, _, cx| { + cx.emit(DismissEvent); }) - .ok(); - - cx.background_executor().timer(Duration::from_secs(3)).await; - this.update(cx, |this, _| this.save_scenario_state.take()) - .ok(); }) - .detach(); + .detach_and_prompt_err("Failed to edit debug.json", window, cx, |_, _, _| None); } fn adapter_drop_down_menu( @@ -544,70 +498,6 @@ impl NewProcessModal { }), ) } - - fn open_debug_json(&self, window: &mut Window, cx: &mut Context) { - let this = cx.entity(); - window - .spawn(cx, async move |cx| { - let worktree_id = this.update(cx, |this, cx| { - let tcx = this.task_contexts(cx); - tcx?.worktree() - })?; - - let Some(worktree_id) = worktree_id else { - let _ = cx.prompt( - PromptLevel::Critical, - "Cannot open debug.json", - Some("You must have at least one project open"), - &[PromptButton::ok("Ok")], - ); - return Ok(()); - }; - - let editor = this - .update_in(cx, |this, window, cx| { - this.workspace.update(cx, |workspace, cx| { - workspace.open_path( - ProjectPath { - worktree_id, - path: local_debug_file_relative_path().into(), - }, - None, - true, - window, - cx, - ) - }) - })?? - .await?; - - cx.update(|_window, cx| { - if let Some(editor) = editor.act_as::(cx) { - editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - if let Some(singleton) = buffer.as_singleton() { - singleton.update(cx, |buffer, cx| { - if buffer.is_empty() { - buffer.edit( - [(0..0, initial_local_debug_tasks_content())], - None, - cx, - ); - } - }) - } - }) - }); - } - }) - .ok(); - - this.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - - anyhow::Ok(()) - }) - .detach(); - } } static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger"); @@ -812,39 +702,21 @@ impl Render for NewProcessModal { NewProcessMode::Launch => el.child( container .child( - h_flex() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .child( - InteractiveText::new( - "open-debug-json", - StyledText::new( - "Open .zed/debug.json for advanced configuration.", - ) - .with_highlights([( - 5..20, - HighlightStyle { - underline: Some(UnderlineStyle { - thickness: px(1.0), - color: None, - wavy: false, - }), - ..Default::default() - }, - )]), - ) - .on_click( - vec![5..20], - { - let this = cx.entity(); - move |_, window, cx| { - this.update(cx, |this, cx| { - this.open_debug_json(window, cx); - }) - } - }, + h_flex().child( + Button::new("edit-custom-debug", "Edit in debug.json") + .on_click(cx.listener(|this, _, window, cx| { + this.save_debug_scenario(window, cx); + })) + .disabled( + self.debugger.is_none() + || self + .configure_mode + .read(cx) + .program + .read(cx) + .is_empty(cx), ), - ), + ), ) .child( Button::new("debugger-spawn", "Start") @@ -862,29 +734,48 @@ impl Render for NewProcessModal { ), ), ), - NewProcessMode::Attach => el.child( + NewProcessMode::Attach => el.child({ + let disabled = self.debugger.is_none() + || self + .attach_mode + .read(cx) + .attach_picker + .read(cx) + .picker + .read(cx) + .delegate + .match_count() + == 0; + let secondary_action = menu::SecondaryConfirm.boxed_clone(); container - .child(div().child(self.adapter_drop_down_menu(window, cx))) + .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( - Button::new("debugger-spawn", "Start") - .on_click(cx.listener(|this, _, window, cx| { - this.start_new_session(window, cx) - })) - .disabled( - self.debugger.is_none() - || self - .attach_mode - .read(cx) - .attach_picker - .read(cx) - .picker - .read(cx) - .delegate - .match_count() - == 0, + h_flex() + .child(div().child(self.adapter_drop_down_menu(window, cx))) + .child( + Button::new("debugger-spawn", "Start") + .on_click(cx.listener(|this, _, window, cx| { + this.start_new_session(window, cx) + })) + .disabled(disabled), ), - ), - ), + ) + }), NewProcessMode::Debug => el, NewProcessMode::Task => el, } @@ -1048,25 +939,6 @@ impl ConfigureMode { ) .checkbox_position(ui::IconPosition::End), ) - .child( - CheckboxWithLabel::new( - "debugger-save-to-debug-json", - Label::new("Save to debug.json") - .size(LabelSize::Small) - .color(Color::Muted), - self.save_to_debug_json, - { - let this = cx.weak_entity(); - move |state, _, cx| { - this.update(cx, |this, _| { - this.save_to_debug_json = *state; - }) - .ok(); - } - }, - ) - .checkbox_position(ui::IconPosition::End), - ) } } @@ -1329,12 +1201,7 @@ impl PickerDelegate for DebugDelegate { } } - fn confirm_input( - &mut self, - _secondary: bool, - window: &mut Window, - cx: &mut Context>, - ) { + fn confirm_input(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { let text = self.prompt.clone(); let (task_context, worktree_id) = self .task_contexts @@ -1364,7 +1231,7 @@ impl PickerDelegate for DebugDelegate { let args = args.collect::>(); let task = task::TaskTemplate { - label: "one-off".to_owned(), + label: "one-off".to_owned(), // TODO: rename using command as label env, command: program, args, @@ -1405,7 +1272,11 @@ impl PickerDelegate for DebugDelegate { .background_spawn(async move { for locator in locators { if let Some(scenario) = - locator.1.create_scenario(&task, "one-off", &adapter).await + // TODO: use a more informative label than "one-off" + locator + .1 + .create_scenario(&task, &task.label, &adapter) + .await { return Some(scenario); } @@ -1439,13 +1310,18 @@ impl PickerDelegate for DebugDelegate { .detach(); } - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + fn confirm( + &mut self, + secondary: bool, + window: &mut Window, + cx: &mut Context>, + ) { let debug_scenario = self .matches .get(self.selected_index()) .and_then(|match_candidate| self.candidates.get(match_candidate.candidate_id).cloned()); - let Some((_, debug_scenario, context)) = debug_scenario else { + let Some((kind, debug_scenario, context)) = debug_scenario else { return; }; @@ -1463,24 +1339,38 @@ impl PickerDelegate for DebugDelegate { }); let DebugScenarioContext { task_context, - active_buffer, + active_buffer: _, worktree_id, } = context; - let active_buffer = active_buffer.and_then(|buffer| buffer.upgrade()); - - send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx); - self.debug_panel - .update(cx, |panel, cx| { - panel.start_session( - debug_scenario, - task_context, - active_buffer, - worktree_id, - window, - cx, - ); + + if secondary { + let Some(kind) = kind else { return }; + let Some(id) = worktree_id else { return }; + let debug_panel = self.debug_panel.clone(); + cx.spawn_in(window, async move |_, cx| { + debug_panel + .update_in(cx, |debug_panel, window, cx| { + debug_panel.go_to_scenario_definition(kind, debug_scenario, id, window, cx) + })? + .await?; + anyhow::Ok(()) }) - .ok(); + .detach(); + } else { + send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx); + self.debug_panel + .update(cx, |panel, cx| { + panel.start_session( + debug_scenario, + task_context, + None, + worktree_id, + window, + cx, + ); + }) + .ok(); + } cx.emit(DismissEvent); } @@ -1498,19 +1388,23 @@ impl PickerDelegate for DebugDelegate { let footer = h_flex() .w_full() .p_1p5() - .justify_end() + .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) - // .child( - // // TODO: add button to open selected task in debug.json - // h_flex().into_any_element(), - // ) + .children({ + let action = menu::SecondaryConfirm.boxed_clone(); + KeyBinding::for_action(&*action, window, cx).map(|keybind| { + Button::new("edit-debug-task", "Edit in debug.json") + .label_size(LabelSize::Small) + .key_binding(keybind) + .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: current_modifiers.secondary(), - } - .boxed_clone(); + let action = picker::ConfirmInput { secondary: false }.boxed_clone(); this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| { Button::new("launch-custom", "Launch Custom") .key_binding(keybind) @@ -1607,3 +1501,35 @@ pub(crate) fn resolve_path(path: &mut String) { ); }; } + +#[cfg(test)] +impl NewProcessModal { + pub(crate) fn set_configure( + &mut self, + program: impl AsRef, + cwd: impl AsRef, + stop_on_entry: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.mode = NewProcessMode::Launch; + self.debugger = Some(dap::adapters::DebugAdapterName("fake-adapter".into())); + + self.configure_mode.update(cx, |configure, cx| { + configure.program.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.set_text(program.as_ref(), window, cx); + }); + + configure.cwd.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.set_text(cwd.as_ref(), window, cx); + }); + + configure.stop_on_entry = match stop_on_entry { + true => ToggleState::Selected, + _ => ToggleState::Unselected, + } + }) + } +} diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index d15244c3496b5cb42bf1b4151e075f624862aec0..3a0ad7a40e60d4dc28f2086b94a0a43186978542 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -11,7 +11,7 @@ use workspace::{Member, Pane, PaneAxis, Workspace}; use crate::session::running::{ self, DebugTerminal, RunningState, SubView, breakpoint_list::BreakpointList, console::Console, - loaded_source_list::LoadedSourceList, module_list::ModuleList, + loaded_source_list::LoadedSourceList, memory_view::MemoryView, module_list::ModuleList, stack_frame_list::StackFrameList, variable_list::VariableList, }; @@ -24,6 +24,7 @@ pub(crate) enum DebuggerPaneItem { Modules, LoadedSources, Terminal, + MemoryView, } impl DebuggerPaneItem { @@ -36,6 +37,7 @@ impl DebuggerPaneItem { DebuggerPaneItem::Modules, DebuggerPaneItem::LoadedSources, DebuggerPaneItem::Terminal, + DebuggerPaneItem::MemoryView, ]; VARIANTS } @@ -43,6 +45,9 @@ impl DebuggerPaneItem { pub(crate) fn is_supported(&self, capabilities: &Capabilities) -> bool { match self { DebuggerPaneItem::Modules => capabilities.supports_modules_request.unwrap_or_default(), + DebuggerPaneItem::MemoryView => capabilities + .supports_read_memory_request + .unwrap_or_default(), DebuggerPaneItem::LoadedSources => capabilities .supports_loaded_sources_request .unwrap_or_default(), @@ -59,6 +64,7 @@ impl DebuggerPaneItem { DebuggerPaneItem::Modules => SharedString::new_static("Modules"), DebuggerPaneItem::LoadedSources => SharedString::new_static("Sources"), DebuggerPaneItem::Terminal => SharedString::new_static("Terminal"), + DebuggerPaneItem::MemoryView => SharedString::new_static("Memory View"), } } pub(crate) fn tab_tooltip(self) -> SharedString { @@ -80,6 +86,7 @@ impl DebuggerPaneItem { DebuggerPaneItem::Terminal => { "Provides an interactive terminal session within the debugging environment." } + DebuggerPaneItem::MemoryView => "Allows inspection of memory contents.", }; SharedString::new_static(tooltip) } @@ -204,6 +211,7 @@ pub(crate) fn deserialize_pane_layout( breakpoint_list: &Entity, loaded_sources: &Entity, terminal: &Entity, + memory_view: &Entity, subscriptions: &mut HashMap, window: &mut Window, cx: &mut Context, @@ -228,6 +236,7 @@ pub(crate) fn deserialize_pane_layout( breakpoint_list, loaded_sources, terminal, + memory_view, subscriptions, window, cx, @@ -298,6 +307,12 @@ pub(crate) fn deserialize_pane_layout( DebuggerPaneItem::Terminal, cx, )), + DebuggerPaneItem::MemoryView => Box::new(SubView::new( + memory_view.focus_handle(cx), + memory_view.clone().into(), + DebuggerPaneItem::MemoryView, + cx, + )), }) .collect(); diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs index 482297b13671a969c166e154b43a6c854f231e5c..73cfef78cc6410196441ff974f09b5abe3d86916 100644 --- a/crates/debugger_ui/src/session.rs +++ b/crates/debugger_ui/src/session.rs @@ -5,14 +5,13 @@ use dap::client::SessionId; use gpui::{ App, Axis, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, }; -use project::Project; use project::debugger::session::Session; use project::worktree_store::WorktreeStore; +use project::{Project, debugger::session::SessionQuirks}; use rpc::proto; use running::RunningState; -use std::{cell::OnceCell, sync::OnceLock}; -use ui::{Indicator, Tooltip, prelude::*}; -use util::truncate_and_trailoff; +use std::cell::OnceCell; +use ui::prelude::*; use workspace::{ CollaboratorId, FollowableItem, ViewId, Workspace, item::{self, Item}, @@ -20,8 +19,8 @@ use workspace::{ pub struct DebugSession { remote_id: Option, - running_state: Entity, - label: OnceLock, + pub(crate) running_state: Entity, + pub(crate) quirks: SessionQuirks, stack_trace_view: OnceCell>, _worktree_store: WeakEntity, workspace: WeakEntity, @@ -57,6 +56,7 @@ impl DebugSession { cx, ) }); + let quirks = session.read(cx).quirks(); cx.new(|cx| Self { _subscriptions: [cx.subscribe(&running_state, |_, _, _, cx| { @@ -64,7 +64,7 @@ impl DebugSession { })], remote_id: None, running_state, - label: OnceLock::new(), + quirks, stack_trace_view: OnceCell::new(), _worktree_store: project.read(cx).worktree_store().downgrade(), workspace, @@ -110,65 +110,28 @@ impl DebugSession { .update(cx, |state, cx| state.shutdown(cx)); } - pub(crate) fn label(&self, cx: &App) -> SharedString { - if let Some(label) = self.label.get() { - return label.clone(); - } - - let session = self.running_state.read(cx).session(); - - self.label - .get_or_init(|| session.read(cx).label()) - .to_owned() - } - - pub(crate) fn running_state(&self) -> &Entity { - &self.running_state - } - - pub(crate) fn label_element(&self, depth: usize, cx: &App) -> AnyElement { - const MAX_LABEL_CHARS: usize = 150; - - let label = self.label(cx); - let label = truncate_and_trailoff(&label, MAX_LABEL_CHARS); - - let is_terminated = self - .running_state - .read(cx) - .session() - .read(cx) - .is_terminated(); - let icon = { - if is_terminated { - Some(Indicator::dot().color(Color::Error)) - } else { - match self - .running_state - .read(cx) - .thread_status(cx) - .unwrap_or_default() - { - project::debugger::session::ThreadStatus::Stopped => { - Some(Indicator::dot().color(Color::Conflict)) - } - _ => Some(Indicator::dot().color(Color::Success)), + pub(crate) fn label(&self, cx: &mut App) -> Option { + let session = self.running_state.read(cx).session().clone(); + session.update(cx, |session, cx| { + let session_label = session.label(); + let quirks = session.quirks(); + let mut single_thread_name = || { + let threads = session.threads(cx); + match threads.as_slice() { + [(thread, _)] => Some(SharedString::from(&thread.name)), + _ => None, } + }; + if quirks.prefer_thread_name { + single_thread_name().or(session_label) + } else { + session_label.or_else(single_thread_name) } - }; + }) + } - h_flex() - .id("session-label") - .tooltip(Tooltip::text(format!("Session {}", self.session_id(cx).0,))) - .ml(depth * px(16.0)) - .gap_2() - .when_some(icon, |this, indicator| this.child(indicator)) - .justify_between() - .child( - Label::new(label) - .size(LabelSize::Small) - .when(is_terminated, |this| this.strikethrough()), - ) - .into_any_element() + pub fn running_state(&self) -> &Entity { + &self.running_state } } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b9f373daa4b6afc96e63817d64b686840a2d0738..2651a94520eddaba74a891e3a46eca9e019ea3f3 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1,16 +1,17 @@ pub(crate) mod breakpoint_list; pub(crate) mod console; pub(crate) mod loaded_source_list; +pub(crate) mod memory_view; pub(crate) mod module_list; pub mod stack_frame_list; pub mod variable_list; - use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration}; use crate::{ ToggleExpandItem, new_process_modal::resolve_path, persistence::{self, DebuggerPaneItem, SerializedLayout}, + session::running::memory_view::MemoryView, }; use super::DebugPanelItemEvent; @@ -34,7 +35,7 @@ use loaded_source_list::LoadedSourceList; use module_list::ModuleList; use project::{ DebugScenarioContext, Project, WorktreeId, - debugger::session::{Session, SessionEvent, ThreadId, ThreadStatus}, + debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus}, terminals::TerminalKind, }; use rpc::proto::ViewId; @@ -81,6 +82,7 @@ pub struct RunningState { _schedule_serialize: Option>, pub(crate) scenario: Option, pub(crate) scenario_context: Option, + memory_view: Entity, } impl RunningState { @@ -676,14 +678,36 @@ impl RunningState { let session_id = session.read(cx).session_id(); let weak_state = cx.weak_entity(); let stack_frame_list = cx.new(|cx| { - StackFrameList::new(workspace.clone(), session.clone(), weak_state, window, cx) + StackFrameList::new( + workspace.clone(), + session.clone(), + weak_state.clone(), + window, + cx, + ) }); let debug_terminal = parent_terminal.unwrap_or_else(|| cx.new(|cx| DebugTerminal::empty(window, cx))); - - let variable_list = - cx.new(|cx| VariableList::new(session.clone(), stack_frame_list.clone(), window, cx)); + let memory_view = cx.new(|cx| { + MemoryView::new( + session.clone(), + workspace.clone(), + stack_frame_list.downgrade(), + window, + cx, + ) + }); + let variable_list = cx.new(|cx| { + VariableList::new( + session.clone(), + stack_frame_list.clone(), + memory_view.clone(), + weak_state.clone(), + window, + cx, + ) + }); let module_list = cx.new(|cx| ModuleList::new(session.clone(), workspace.clone(), cx)); @@ -770,6 +794,15 @@ impl RunningState { cx.on_focus_out(&focus_handle, window, |this, _, window, cx| { this.serialize_layout(window, cx); }), + cx.subscribe( + &session, + |this, session, event: &SessionStateEvent, cx| match event { + SessionStateEvent::Shutdown if session.read(cx).is_building() => { + this.shutdown(cx); + } + _ => {} + }, + ), ]; let mut pane_close_subscriptions = HashMap::default(); @@ -786,6 +819,7 @@ impl RunningState { &breakpoint_list, &loaded_source_list, &debug_terminal, + &memory_view, &mut pane_close_subscriptions, window, cx, @@ -814,6 +848,7 @@ impl RunningState { let active_pane = panes.first_pane(); Self { + memory_view, session, workspace, focus_handle, @@ -884,6 +919,7 @@ impl RunningState { let weak_project = project.downgrade(); let weak_workspace = workspace.downgrade(); let is_local = project.read(cx).is_local(); + cx.spawn_in(window, async move |this, cx| { let DebugScenario { adapter, @@ -973,7 +1009,7 @@ impl RunningState { let task_with_shell = SpawnInTerminal { command_label, - command, + command: Some(command), args, ..task.resolved.clone() }; @@ -1085,19 +1121,6 @@ impl RunningState { .map(PathBuf::from) .or_else(|| session.binary().unwrap().cwd.clone()); - let mut args = request.args.clone(); - - // Handle special case for NodeJS debug adapter - // If only the Node binary path is provided, we set the command to None - // This prevents the NodeJS REPL from appearing, which is not the desired behavior - // The expected usage is for users to provide their own Node command, e.g., `node test.js` - // This allows the NodeJS debug client to attach correctly - let command = if args.len() > 1 { - Some(args.remove(0)) - } else { - None - }; - let mut envs: HashMap = self.session.read(cx).task_context().project_env.clone(); if let Some(Value::Object(env)) = &request.env { @@ -1111,32 +1134,58 @@ impl RunningState { } } - let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); - let kind = if let Some(command) = command { - let title = request.title.clone().unwrap_or(command.clone()); - TerminalKind::Task(task::SpawnInTerminal { - id: task::TaskId("debug".to_string()), - full_label: title.clone(), - label: title.clone(), - command: command.clone(), - args, - command_label: title.clone(), - cwd, - env: envs, - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: task::RevealStrategy::NoFocus, - reveal_target: task::RevealTarget::Dock, - hide: task::HideStrategy::Never, - shell, - show_summary: false, - show_command: false, - show_rerun: false, - }) + let mut args = request.args.clone(); + let command = if envs.contains_key("VSCODE_INSPECTOR_OPTIONS") { + // Handle special case for NodeJS debug adapter + // If the Node binary path is provided (possibly with arguments like --experimental-network-inspection), + // we set the command to None + // This prevents the NodeJS REPL from appearing, which is not the desired behavior + // The expected usage is for users to provide their own Node command, e.g., `node test.js` + // This allows the NodeJS debug client to attach correctly + if args + .iter() + .filter(|arg| !arg.starts_with("--")) + .collect::>() + .len() + > 1 + { + Some(args.remove(0)) + } else { + None + } + } else if args.len() > 0 { + Some(args.remove(0)) } else { - TerminalKind::Shell(cwd.map(|c| c.to_path_buf())) + None }; + let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); + let title = request + .title + .clone() + .filter(|title| !title.is_empty()) + .or_else(|| command.clone()) + .unwrap_or_else(|| "Debug terminal".to_string()); + let kind = TerminalKind::Task(task::SpawnInTerminal { + id: task::TaskId("debug".to_string()), + full_label: title.clone(), + label: title.clone(), + command: command.clone(), + args, + command_label: title.clone(), + cwd, + env: envs, + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: task::RevealStrategy::NoFocus, + reveal_target: task::RevealTarget::Dock, + hide: task::HideStrategy::Never, + shell, + show_summary: false, + show_command: false, + show_rerun: false, + }); + let workspace = self.workspace.clone(); let weak_project = project.downgrade(); @@ -1211,6 +1260,12 @@ impl RunningState { item_kind, cx, )), + DebuggerPaneItem::MemoryView => Box::new(SubView::new( + self.memory_view.focus_handle(cx), + self.memory_view.clone().into(), + item_kind, + cx, + )), } } @@ -1395,7 +1450,14 @@ impl RunningState { &self.module_list } - pub(crate) fn activate_item(&self, item: DebuggerPaneItem, window: &mut Window, cx: &mut App) { + pub(crate) fn activate_item( + &mut self, + item: DebuggerPaneItem, + window: &mut Window, + cx: &mut Context, + ) { + self.ensure_pane_item(item, window, cx); + let (variable_list_position, pane) = self .panes .panes() @@ -1407,9 +1469,10 @@ impl RunningState { .map(|view| (view, pane)) }) .unwrap(); + pane.update(cx, |this, cx| { this.activate_item(variable_list_position, true, true, window, cx); - }) + }); } #[cfg(test)] @@ -1446,7 +1509,7 @@ impl RunningState { } } - pub(crate) fn selected_thread_id(&self) -> Option { + pub fn selected_thread_id(&self) -> Option { self.thread_id } @@ -1586,9 +1649,21 @@ impl RunningState { }) .log_err(); - self.session.update(cx, |session, cx| { + let is_building = self.session.update(cx, |session, cx| { session.shutdown(cx).detach(); - }) + matches!(session.mode, session::SessionState::Building(_)) + }); + + if is_building { + self.debug_terminal.update(cx, |terminal, cx| { + if let Some(view) = terminal.terminal.as_ref() { + view.update(cx, |view, cx| { + view.terminal() + .update(cx, |terminal, _| terminal.kill_active_task()) + }) + } + }) + } } pub fn stop_thread(&self, cx: &mut Context) { diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 5576435a0875ae298a7a7f5fb9d509a6a7ea16f1..6ac4b1c878e448cd98ba8aa3f297b1c642151c4e 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -5,7 +5,8 @@ use std::{ time::Duration, }; -use dap::{Capabilities, ExceptionBreakpointsFilter}; +use dap::{Capabilities, ExceptionBreakpointsFilter, adapters::DebugAdapterName}; +use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, @@ -16,16 +17,17 @@ use project::{ Project, debugger::{ breakpoint_store::{BreakpointEditAction, BreakpointStore, SourceBreakpoint}, + dap_store::{DapStore, PersistedAdapterOptions}, session::Session, }, worktree_store::WorktreeStore, }; use ui::{ ActiveTheme, AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div, - Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, Indicator, - InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement, - Render, RenderOnce, Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, - Styled, Toggleable, Tooltip, Window, div, h_flex, px, v_flex, + Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, InteractiveElement, + IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement, Render, RenderOnce, + Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, Toggleable, + Tooltip, Window, div, h_flex, px, v_flex, }; use util::ResultExt; use workspace::Workspace; @@ -33,16 +35,23 @@ use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint}; actions!( debugger, - [PreviousBreakpointProperty, NextBreakpointProperty] + [ + /// Navigates to the previous breakpoint property in the list. + PreviousBreakpointProperty, + /// Navigates to the next breakpoint property in the list. + NextBreakpointProperty + ] ); #[derive(Clone, Copy, PartialEq)] pub(crate) enum SelectedBreakpointKind { Source, Exception, + Data, } pub(crate) struct BreakpointList { workspace: WeakEntity, breakpoint_store: Entity, + dap_store: Entity, worktree_store: Entity, scrollbar_state: ScrollbarState, breakpoints: Vec, @@ -54,6 +63,7 @@ pub(crate) struct BreakpointList { selected_ix: Option, input: Entity, strip_mode: Option, + serialize_exception_breakpoints_task: Option>>, } impl Focusable for BreakpointList { @@ -80,24 +90,34 @@ impl BreakpointList { let project = project.read(cx); let breakpoint_store = project.breakpoint_store(); let worktree_store = project.worktree_store(); + 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()); - cx.new(|cx| Self { - breakpoint_store, - worktree_store, - scrollbar_state, - breakpoints: Default::default(), - hide_scrollbar_task: None, - show_scrollbar: false, - workspace, - session, - focus_handle, - scroll_handle, - selected_ix: None, - input: cx.new(|cx| Editor::single_line(window, cx)), - strip_mode: None, + let adapter_name = session.as_ref().map(|session| session.read(cx).adapter()); + cx.new(|cx| { + let this = Self { + breakpoint_store, + dap_store, + worktree_store, + scrollbar_state, + breakpoints: Default::default(), + hide_scrollbar_task: None, + show_scrollbar: false, + workspace, + session, + focus_handle, + scroll_handle, + selected_ix: None, + input: cx.new(|cx| Editor::single_line(window, cx)), + strip_mode: None, + serialize_exception_breakpoints_task: None, + }; + if let Some(name) = adapter_name { + _ = this.deserialize_exception_breakpoints(name, cx); + } + this }) } @@ -169,6 +189,9 @@ impl BreakpointList { BreakpointEntryKind::ExceptionBreakpoint(bp) => { (SelectedBreakpointKind::Exception, bp.is_enabled) } + BreakpointEntryKind::DataBreakpoint(bp) => { + (SelectedBreakpointKind::Data, bp.0.is_enabled) + } }) }) } @@ -372,7 +395,8 @@ impl BreakpointList { let row = line_breakpoint.breakpoint.row; self.go_to_line_breakpoint(path, row, window, cx); } - BreakpointEntryKind::ExceptionBreakpoint(_) => {} + BreakpointEntryKind::DataBreakpoint(_) + | BreakpointEntryKind::ExceptionBreakpoint(_) => {} } } @@ -399,12 +423,12 @@ impl BreakpointList { self.edit_line_breakpoint(path, row, BreakpointEditAction::InvertState, cx); } BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => { - if let Some(session) = &self.session { - let id = exception_breakpoint.id.clone(); - session.update(cx, |session, cx| { - session.toggle_exception_breakpoint(&id, cx); - }); - } + let id = exception_breakpoint.id.clone(); + self.toggle_exception_breakpoint(&id, cx); + } + BreakpointEntryKind::DataBreakpoint(data_breakpoint) => { + let id = data_breakpoint.0.dap.data_id.clone(); + self.toggle_data_breakpoint(&id, cx); } } cx.notify(); @@ -426,7 +450,7 @@ impl BreakpointList { let row = line_breakpoint.breakpoint.row; self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx); } - BreakpointEntryKind::ExceptionBreakpoint(_) => {} + _ => {} } cx.notify(); } @@ -475,6 +499,72 @@ impl BreakpointList { cx.notify(); } + 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); + }); + } + } + + 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); + }); + cx.notify(); + const EXCEPTION_SERIALIZATION_INTERVAL: Duration = Duration::from_secs(1); + self.serialize_exception_breakpoints_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(EXCEPTION_SERIALIZATION_INTERVAL) + .await; + this.update(cx, |this, cx| this.serialize_exception_breakpoints(cx))? + .await?; + Ok(()) + })); + } + } + + fn kvp_key(adapter_name: &str) -> String { + format!("debug_adapter_`{adapter_name}`_persistence") + } + fn serialize_exception_breakpoints( + &mut self, + cx: &mut Context, + ) -> Task> { + if let Some(session) = self.session.as_ref() { + let key = { + let session = session.read(cx); + let name = session.adapter().0; + Self::kvp_key(&name) + }; + let settings = self.dap_store.update(cx, |this, cx| { + this.sync_adapter_options(session, cx); + }); + let value = serde_json::to_string(&settings); + + cx.background_executor() + .spawn(async move { KEY_VALUE_STORE.write_kvp(key, value?).await }) + } else { + return Task::ready(Result::Ok(())); + } + } + + fn deserialize_exception_breakpoints( + &self, + adapter_name: DebugAdapterName, + cx: &mut Context, + ) -> anyhow::Result<()> { + let Some(val) = KEY_VALUE_STORE.read_kvp(&Self::kvp_key(&adapter_name))? else { + return Ok(()); + }; + let value: PersistedAdapterOptions = serde_json::from_str(&val)?; + self.dap_store + .update(cx, |this, _| this.set_adapter_options(adapter_name, value)); + + Ok(()) + } + fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { @@ -569,6 +659,7 @@ impl BreakpointList { SelectedBreakpointKind::Exception => { "Exception Breakpoints cannot be removed from the breakpoint list" } + SelectedBreakpointKind::Data => "Remove data breakpoint from a breakpoint list", }); let toggle_label = selection_kind.map(|(_, is_enabled)| { if is_enabled { @@ -710,8 +801,20 @@ impl Render for BreakpointList { weak: weak.clone(), }) }); - self.breakpoints - .extend(breakpoints.chain(exception_breakpoints)); + let data_breakpoints = self.session.as_ref().into_iter().flat_map(|session| { + session + .read(cx) + .data_breakpoints() + .map(|state| BreakpointEntry { + kind: BreakpointEntryKind::DataBreakpoint(DataBreakpoint(state.clone())), + weak: weak.clone(), + }) + }); + self.breakpoints.extend( + breakpoints + .chain(data_breakpoints) + .chain(exception_breakpoints), + ); v_flex() .id("breakpoint-list") .key_context("BreakpointList") @@ -832,7 +935,11 @@ impl LineBreakpoint { .ok(); } }) - .child(Indicator::icon(Icon::new(icon_name)).color(Color::Debugger)) + .child( + Icon::new(icon_name) + .color(Color::Debugger) + .size(IconSize::XSmall), + ) .on_mouse_down(MouseButton::Left, move |_, _, _| {}); ListItem::new(SharedString::from(format!( @@ -923,6 +1030,103 @@ struct ExceptionBreakpoint { data: ExceptionBreakpointsFilter, is_enabled: bool, } +#[derive(Clone, Debug)] +struct DataBreakpoint(project::debugger::session::DataBreakpointState); + +impl DataBreakpoint { + fn render( + &self, + props: SupportedBreakpointProperties, + strip_mode: Option, + ix: usize, + is_selected: bool, + focus_handle: FocusHandle, + list: WeakEntity, + ) -> ListItem { + let color = if self.0.is_enabled { + Color::Debugger + } else { + Color::Muted + }; + let is_enabled = self.0.is_enabled; + let id = self.0.dap.data_id.clone(); + ListItem::new(SharedString::from(format!( + "data-breakpoint-ui-item-{}", + self.0.dap.data_id + ))) + .rounded() + .start_slot( + div() + .id(SharedString::from(format!( + "data-breakpoint-ui-item-{}-click-handler", + self.0.dap.data_id + ))) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + if is_enabled { + "Disable Data Breakpoint" + } else { + "Enable Data Breakpoint" + }, + &ToggleEnableBreakpoint, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let list = list.clone(); + move |_, _, cx| { + list.update(cx, |this, cx| { + this.toggle_data_breakpoint(&id, cx); + }) + .ok(); + } + }) + .cursor_pointer() + .child( + Icon::new(IconName::Binary) + .color(color) + .size(IconSize::Small), + ), + ) + .child( + h_flex() + .w_full() + .mr_4() + .py_0p5() + .justify_between() + .child( + v_flex() + .py_1() + .gap_1() + .min_h(px(26.)) + .justify_center() + .id(("data-breakpoint-label", ix)) + .child( + Label::new(self.0.context.human_readable_label()) + .size(LabelSize::Small) + .line_height_style(ui::LineHeightStyle::UiLabel), + ), + ) + .child(BreakpointOptionsStrip { + props, + breakpoint: BreakpointEntry { + kind: BreakpointEntryKind::DataBreakpoint(self.clone()), + weak: list, + }, + is_selected, + focus_handle, + strip_mode, + index: ix, + }), + ) + .toggle_state(is_selected) + } +} impl ExceptionBreakpoint { fn render( @@ -983,18 +1187,17 @@ impl ExceptionBreakpoint { let list = list.clone(); move |_, _, cx| { list.update(cx, |this, cx| { - if let Some(session) = &this.session { - session.update(cx, |this, cx| { - this.toggle_exception_breakpoint(&id, cx); - }); - cx.notify(); - } + this.toggle_exception_breakpoint(&id, cx); }) .ok(); } }) .cursor_pointer() - .child(Indicator::icon(Icon::new(IconName::Flame)).color(color)), + .child( + Icon::new(IconName::Flame) + .color(color) + .size(IconSize::Small), + ), ) .child( h_flex() @@ -1037,6 +1240,7 @@ impl ExceptionBreakpoint { enum BreakpointEntryKind { LineBreakpoint(LineBreakpoint), ExceptionBreakpoint(ExceptionBreakpoint), + DataBreakpoint(DataBreakpoint), } #[derive(Clone, Debug)] @@ -1072,6 +1276,14 @@ impl BreakpointEntry { focus_handle, self.weak.clone(), ), + BreakpointEntryKind::DataBreakpoint(data_breakpoint) => data_breakpoint.render( + props.for_data_breakpoints(), + strip_mode, + ix, + is_selected, + focus_handle, + self.weak.clone(), + ), } } @@ -1087,6 +1299,11 @@ impl BreakpointEntry { exception_breakpoint.id ) .into(), + BreakpointEntryKind::DataBreakpoint(data_breakpoint) => format!( + "data-breakpoint-control-strip--{}", + data_breakpoint.0.dap.data_id + ) + .into(), } } @@ -1104,8 +1321,8 @@ impl BreakpointEntry { BreakpointEntryKind::LineBreakpoint(line_breakpoint) => { line_breakpoint.breakpoint.condition.is_some() } - // We don't support conditions on exception breakpoints - BreakpointEntryKind::ExceptionBreakpoint(_) => false, + // We don't support conditions on exception/data breakpoints + _ => false, } } @@ -1157,6 +1374,10 @@ impl SupportedBreakpointProperties { // TODO: we don't yet support conditions for exception breakpoints at the data layer, hence all props are disabled here. Self::empty() } + fn for_data_breakpoints(self) -> Self { + // TODO: we don't yet support conditions for data breakpoints at the data layer, hence all props are disabled here. + Self::empty() + } } #[derive(IntoElement)] struct BreakpointOptionsStrip { diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index aaac63640188b2b277d1ff8bfb9b75b114f5554b..1385bec54ef77222485cd642174d50aa60fa289a 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -5,25 +5,35 @@ use super::{ use alacritty_terminal::vte::ansi; use anyhow::Result; use collections::HashMap; -use dap::OutputEvent; +use dap::{CompletionItem, CompletionItemType, OutputEvent}; use editor::{Bias, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId}; use fuzzy::StringMatchCandidate; use gpui::{ Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, Render, Subscription, Task, TextStyle, WeakEntity, actions, }; -use language::{Buffer, CodeLabel, ToOffset}; -use menu::Confirm; +use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset}; +use menu::{Confirm, SelectNext, SelectPrevious}; use project::{ Completion, CompletionResponse, - debugger::session::{CompletionsQuery, OutputToken, Session, SessionEvent}, + debugger::session::{CompletionsQuery, OutputToken, Session}, + lsp_store::CompletionDocumentation, + search_history::{SearchHistory, SearchHistoryCursor}, }; use settings::Settings; +use std::fmt::Write; use std::{cell::RefCell, ops::Range, rc::Rc, usize}; use theme::{Theme, ThemeSettings}; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; +use util::ResultExt; -actions!(console, [WatchExpression]); +actions!( + console, + [ + /// Adds an expression to the watch list. + WatchExpression + ] +); pub struct Console { console: Entity, @@ -33,8 +43,10 @@ pub struct Console { variable_list: Entity, stack_frame_list: Entity, last_token: OutputToken, - update_output_task: Task<()>, + update_output_task: Option>, focus_handle: FocusHandle, + history: SearchHistory, + cursor: SearchHistoryCursor, } impl Console { @@ -83,11 +95,6 @@ impl Console { let _subscriptions = vec![ cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), - cx.subscribe_in(&session, window, |this, _, event, window, cx| { - if let SessionEvent::ConsoleOutput = event { - this.update_output(window, cx) - } - }), cx.on_focus(&focus_handle, window, |console, window, cx| { if console.is_running(cx) { console.query_bar.focus_handle(cx).focus(window); @@ -102,9 +109,14 @@ impl Console { variable_list, _subscriptions, stack_frame_list, - update_output_task: Task::ready(()), + update_output_task: None, last_token: OutputToken(0), focus_handle, + history: SearchHistory::new( + None, + project::search_history::QueryInsertionBehavior::ReplacePreviousIfContains, + ), + cursor: Default::default(), } } @@ -133,202 +145,116 @@ impl Console { self.session.read(cx).has_new_output(self.last_token) } - pub fn add_messages<'a>( + fn add_messages( &mut self, - events: impl Iterator, + events: Vec, window: &mut Window, cx: &mut App, - ) { - self.console.update(cx, |console, cx| { - console.set_read_only(false); - - for event in events { - let to_insert = format!("{}\n", event.output.trim_end()); - - let mut ansi_handler = ConsoleHandler::default(); - let mut ansi_processor = ansi::Processor::::default(); - - let len = console.buffer().read(cx).len(cx); - ansi_processor.advance(&mut ansi_handler, to_insert.as_bytes()); - let output = std::mem::take(&mut ansi_handler.output); - let mut spans = std::mem::take(&mut ansi_handler.spans); - let mut background_spans = std::mem::take(&mut ansi_handler.background_spans); - if ansi_handler.current_range_start < output.len() { - spans.push(( - ansi_handler.current_range_start..output.len(), - ansi_handler.current_color, - )); - } - if ansi_handler.current_background_range_start < output.len() { - background_spans.push(( - ansi_handler.current_background_range_start..output.len(), - ansi_handler.current_background_color, - )); - } - console.move_to_end(&editor::actions::MoveToEnd, window, cx); - console.insert(&output, window, cx); - let buffer = console.buffer().read(cx).snapshot(cx); - - struct ConsoleAnsiHighlight; - - for (range, color) in spans { - let Some(color) = color else { continue }; - let start_offset = len + range.start; - let range = start_offset..len + range.end; - let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end); - let style = HighlightStyle { - color: Some(terminal_view::terminal_element::convert_color( - &color, - cx.theme(), - )), - ..Default::default() - }; - console.highlight_text_key::( - start_offset, - vec![range], - style, - cx, - ); - } - - for (range, color) in background_spans { - let Some(color) = color else { continue }; - let start_offset = len + range.start; - let range = start_offset..len + range.end; - let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end); - - let color_fetcher: fn(&Theme) -> Hsla = match color { - // Named and theme defined colors - ansi::Color::Named(n) => match n { - ansi::NamedColor::Black => |theme| theme.colors().terminal_ansi_black, - ansi::NamedColor::Red => |theme| theme.colors().terminal_ansi_red, - ansi::NamedColor::Green => |theme| theme.colors().terminal_ansi_green, - ansi::NamedColor::Yellow => |theme| theme.colors().terminal_ansi_yellow, - ansi::NamedColor::Blue => |theme| theme.colors().terminal_ansi_blue, - ansi::NamedColor::Magenta => { - |theme| theme.colors().terminal_ansi_magenta - } - ansi::NamedColor::Cyan => |theme| theme.colors().terminal_ansi_cyan, - ansi::NamedColor::White => |theme| theme.colors().terminal_ansi_white, - ansi::NamedColor::BrightBlack => { - |theme| theme.colors().terminal_ansi_bright_black - } - ansi::NamedColor::BrightRed => { - |theme| theme.colors().terminal_ansi_bright_red - } - ansi::NamedColor::BrightGreen => { - |theme| theme.colors().terminal_ansi_bright_green - } - ansi::NamedColor::BrightYellow => { - |theme| theme.colors().terminal_ansi_bright_yellow - } - ansi::NamedColor::BrightBlue => { - |theme| theme.colors().terminal_ansi_bright_blue - } - ansi::NamedColor::BrightMagenta => { - |theme| theme.colors().terminal_ansi_bright_magenta + ) -> Task> { + self.console.update(cx, |_, cx| { + cx.spawn_in(window, async move |console, cx| { + let mut len = console.update(cx, |this, cx| this.buffer().read(cx).len(cx))?; + let (output, spans, background_spans) = cx + .background_spawn(async move { + let mut all_spans = Vec::new(); + let mut all_background_spans = Vec::new(); + let mut to_insert = String::new(); + let mut scratch = String::new(); + + for event in &events { + scratch.clear(); + let mut ansi_handler = ConsoleHandler::default(); + let mut ansi_processor = + ansi::Processor::::default(); + + let trimmed_output = event.output.trim_end(); + let _ = writeln!(&mut scratch, "{trimmed_output}"); + ansi_processor.advance(&mut ansi_handler, scratch.as_bytes()); + let output = std::mem::take(&mut ansi_handler.output); + to_insert.extend(output.chars()); + let mut spans = std::mem::take(&mut ansi_handler.spans); + let mut background_spans = + std::mem::take(&mut ansi_handler.background_spans); + if ansi_handler.current_range_start < output.len() { + spans.push(( + ansi_handler.current_range_start..output.len(), + ansi_handler.current_color, + )); } - ansi::NamedColor::BrightCyan => { - |theme| theme.colors().terminal_ansi_bright_cyan + if ansi_handler.current_background_range_start < output.len() { + background_spans.push(( + ansi_handler.current_background_range_start..output.len(), + ansi_handler.current_background_color, + )); } - ansi::NamedColor::BrightWhite => { - |theme| theme.colors().terminal_ansi_bright_white - } - ansi::NamedColor::Foreground => { - |theme| theme.colors().terminal_foreground - } - ansi::NamedColor::Background => { - |theme| theme.colors().terminal_background - } - ansi::NamedColor::Cursor => |theme| theme.players().local().cursor, - ansi::NamedColor::DimBlack => { - |theme| theme.colors().terminal_ansi_dim_black - } - ansi::NamedColor::DimRed => { - |theme| theme.colors().terminal_ansi_dim_red - } - ansi::NamedColor::DimGreen => { - |theme| theme.colors().terminal_ansi_dim_green - } - ansi::NamedColor::DimYellow => { - |theme| theme.colors().terminal_ansi_dim_yellow - } - ansi::NamedColor::DimBlue => { - |theme| theme.colors().terminal_ansi_dim_blue - } - ansi::NamedColor::DimMagenta => { - |theme| theme.colors().terminal_ansi_dim_magenta - } - ansi::NamedColor::DimCyan => { - |theme| theme.colors().terminal_ansi_dim_cyan - } - ansi::NamedColor::DimWhite => { - |theme| theme.colors().terminal_ansi_dim_white - } - ansi::NamedColor::BrightForeground => { - |theme| theme.colors().terminal_bright_foreground - } - ansi::NamedColor::DimForeground => { - |theme| theme.colors().terminal_dim_foreground + + for (range, _) in spans.iter_mut() { + let start_offset = len + range.start; + *range = start_offset..len + range.end; } - }, - // 'True' colors - ansi::Color::Spec(_) => |theme| theme.colors().editor_background, - // 8 bit, indexed colors - ansi::Color::Indexed(i) => { - match i { - // 0-15 are the same as the named colors above - 0 => |theme| theme.colors().terminal_ansi_black, - 1 => |theme| theme.colors().terminal_ansi_red, - 2 => |theme| theme.colors().terminal_ansi_green, - 3 => |theme| theme.colors().terminal_ansi_yellow, - 4 => |theme| theme.colors().terminal_ansi_blue, - 5 => |theme| theme.colors().terminal_ansi_magenta, - 6 => |theme| theme.colors().terminal_ansi_cyan, - 7 => |theme| theme.colors().terminal_ansi_white, - 8 => |theme| theme.colors().terminal_ansi_bright_black, - 9 => |theme| theme.colors().terminal_ansi_bright_red, - 10 => |theme| theme.colors().terminal_ansi_bright_green, - 11 => |theme| theme.colors().terminal_ansi_bright_yellow, - 12 => |theme| theme.colors().terminal_ansi_bright_blue, - 13 => |theme| theme.colors().terminal_ansi_bright_magenta, - 14 => |theme| theme.colors().terminal_ansi_bright_cyan, - 15 => |theme| theme.colors().terminal_ansi_bright_white, - // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm. - // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl - // 16..=231 => { - // let (r, g, b) = rgb_for_index(index as u8); - // rgba_color( - // if r == 0 { 0 } else { r * 40 + 55 }, - // if g == 0 { 0 } else { g * 40 + 55 }, - // if b == 0 { 0 } else { b * 40 + 55 }, - // ) - // } - // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238). - // 232..=255 => { - // let i = index as u8 - 232; // Align index to 0..24 - // let value = i * 10 + 8; - // rgba_color(value, value, value) - // } - // For compatibility with the alacritty::Colors interface - // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs - _ => |_| gpui::black(), + + for (range, _) in background_spans.iter_mut() { + let start_offset = len + range.start; + *range = start_offset..len + range.end; } + + len += output.len(); + + all_spans.extend(spans); + all_background_spans.extend(background_spans); } - }; - - console.highlight_background_key::( - start_offset, - &[range], - color_fetcher, - cx, - ); - } - } + (to_insert, all_spans, all_background_spans) + }) + .await; + console.update_in(cx, |console, window, cx| { + console.set_read_only(false); + console.move_to_end(&editor::actions::MoveToEnd, window, cx); + console.insert(&output, window, cx); + console.set_read_only(true); + + struct ConsoleAnsiHighlight; + + let buffer = console.buffer().read(cx).snapshot(cx); + + for (range, color) in spans { + let Some(color) = color else { continue }; + let start_offset = range.start; + let range = + buffer.anchor_after(range.start)..buffer.anchor_before(range.end); + let style = HighlightStyle { + color: Some(terminal_view::terminal_element::convert_color( + &color, + cx.theme(), + )), + ..Default::default() + }; + console.highlight_text_key::( + start_offset, + vec![range], + style, + cx, + ); + } - console.set_read_only(true); - cx.notify(); - }); + for (range, color) in background_spans { + let Some(color) = color else { continue }; + let start_offset = range.start; + let range = + buffer.anchor_after(range.start)..buffer.anchor_before(range.end); + console.highlight_background_key::( + start_offset, + &[range], + color_fetcher(color), + cx, + ); + } + + cx.notify(); + })?; + + Ok(()) + }) + }) } pub fn watch_expression( @@ -345,7 +271,8 @@ impl Console { expression }); - + self.history.add(&mut self.cursor, expression.clone()); + self.cursor.reset(); self.session.update(cx, |session, cx| { session .evaluate( @@ -365,7 +292,28 @@ impl Console { }); } - pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + fn previous_query(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + let prev = self.history.previous(&mut self.cursor); + if let Some(prev) = prev { + self.query_bar.update(cx, |editor, cx| { + editor.set_text(prev, window, cx); + }); + } + } + + fn next_query(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + let next = self.history.next(&mut self.cursor); + let query = next.unwrap_or_else(|| { + self.cursor.reset(); + "" + }); + + self.query_bar.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + } + + fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { let expression = self.query_bar.update(cx, |editor, cx| { let expression = editor.text(cx); cx.defer_in(window, |editor, window, cx| { @@ -375,6 +323,8 @@ impl Console { expression }); + self.history.add(&mut self.cursor, expression.clone()); + self.cursor.reset(); self.session.update(cx, |session, cx| { session .evaluate( @@ -458,31 +408,50 @@ impl Console { EditorElement::new(&self.query_bar, Self::editor_style(&self.query_bar, cx)) } - fn update_output(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn update_output(&mut self, window: &mut Window, cx: &mut Context) { + if self.update_output_task.is_some() { + return; + } let session = self.session.clone(); let token = self.last_token; - - self.update_output_task = cx.spawn_in(window, async move |this, cx| { - _ = session.update_in(cx, move |session, window, cx| { - let (output, last_processed_token) = session.output(token); - - _ = this.update(cx, |this, cx| { - if last_processed_token == this.last_token { - return; - } - this.add_messages(output, window, cx); - - this.last_token = last_processed_token; + self.update_output_task = Some(cx.spawn_in(window, async move |this, cx| { + let Some((last_processed_token, task)) = session + .update_in(cx, |session, window, cx| { + let (output, last_processed_token) = session.output(token); + + this.update(cx, |this, cx| { + if last_processed_token == this.last_token { + return None; + } + Some(( + last_processed_token, + this.add_messages(output.cloned().collect(), window, cx), + )) + }) + .ok() + .flatten() + }) + .ok() + .flatten() + else { + _ = this.update(cx, |this, _| { + this.update_output_task.take(); }); + return; + }; + _ = task.await.log_err(); + _ = this.update(cx, |this, _| { + this.last_token = last_processed_token; + this.update_output_task.take(); }); - }); + })); } } impl Render for Console { - 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 query_focus_handle = self.query_bar.focus_handle(cx); - + self.update_output(window, cx); v_flex() .track_focus(&self.focus_handle) .key_context("DebugConsole") @@ -493,6 +462,8 @@ impl Render for Console { .when(self.is_running(cx), |this| { this.child(Divider::horizontal()).child( h_flex() + .on_action(cx.listener(Self::previous_query)) + .on_action(cx.listener(Self::next_query)) .gap_1() .bg(cx.theme().colors().editor_background) .child(self.render_query_bar(cx)) @@ -585,15 +556,27 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { buffer: &Entity, position: language::Anchor, text: &str, - _trigger_in_words: bool, + trigger_in_words: bool, menu_is_open: bool, cx: &mut Context, ) -> bool { + let mut chars = text.chars(); + let char = if let Some(char) = chars.next() { + char + } else { + return false; + }; + let snapshot = buffer.read(cx).snapshot(); 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); + if trigger_in_words && classifier.is_word(char) { + return true; + } + self.0 .read_with(cx, |console, cx| { console @@ -626,48 +609,41 @@ impl ConsoleQueryBarCompletionProvider { variable_list.completion_variables(cx) }) { if let Some(evaluate_name) = &variable.evaluate_name { - variables.insert(evaluate_name.clone(), variable.value.clone()); + if 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(), + }); + } + } + + if variables + .insert(variable.name.clone(), variable.value.clone()) + .is_none() + { string_matches.push(StringMatchCandidate { id: 0, - string: evaluate_name.clone(), - char_bag: evaluate_name.chars().collect(), + string: variable.name.clone(), + char_bag: variable.name.chars().collect(), }); } - - variables.insert(variable.name.clone(), variable.value.clone()); - - string_matches.push(StringMatchCandidate { - id: 0, - string: variable.name.clone(), - char_bag: variable.name.chars().collect(), - }); } (variables, string_matches) }); let snapshot = buffer.read(cx).text_snapshot(); - let query = snapshot.text(); - let replace_range = { - let buffer_offset = buffer_position.to_offset(&snapshot); - let reversed_chars = snapshot.reversed_chars_for_range(0..buffer_offset); - let mut word_len = 0; - for ch in reversed_chars { - if ch.is_alphanumeric() || ch == '_' { - word_len += 1; - } else { - break; - } - } - let word_start_offset = buffer_offset - word_len; - let start_anchor = snapshot.anchor_at(word_start_offset, Bias::Left); - start_anchor..buffer_position - }; + let buffer_text = snapshot.text(); + cx.spawn(async move |_, cx| { const LIMIT: usize = 10; let matches = fuzzy::match_strings( &string_matches, - &query, + &buffer_text, true, true, LIMIT, @@ -682,15 +658,22 @@ impl ConsoleQueryBarCompletionProvider { let variable_value = variables.get(&string_match.string)?; Some(project::Completion { - replace_range: replace_range.clone(), + replace_range: Self::replace_range_for_completion( + &buffer_text, + buffer_position, + string_match.string.as_bytes(), + &snapshot, + ), new_text: string_match.string.clone(), label: CodeLabel { filter_range: 0..string_match.string.len(), - text: format!("{} {}", string_match.string, variable_value), + text: string_match.string.clone(), runs: Vec::new(), }, icon_path: None, - documentation: None, + documentation: Some(CompletionDocumentation::MultiLineMarkdown( + variable_value.into(), + )), confirm: None, source: project::CompletionSource::Custom, insert_text_mode: None, @@ -705,6 +688,54 @@ impl ConsoleQueryBarCompletionProvider { }) } + fn replace_range_for_completion( + buffer_text: &String, + buffer_position: Anchor, + new_bytes: &[u8], + snapshot: &TextBufferSnapshot, + ) -> Range { + let buffer_offset = buffer_position.to_offset(&snapshot); + let buffer_bytes = &buffer_text.as_bytes()[0..buffer_offset]; + + let mut prefix_len = 0; + for i in (0..new_bytes.len()).rev() { + if buffer_bytes.ends_with(&new_bytes[0..i]) { + prefix_len = i; + break; + } + } + + let start = snapshot.clip_offset(buffer_offset - prefix_len, Bias::Left); + + snapshot.anchor_before(start)..buffer_position + } + + const fn completion_type_score(completion_type: CompletionItemType) -> usize { + match completion_type { + CompletionItemType::Field | CompletionItemType::Property => 0, + CompletionItemType::Variable | CompletionItemType::Value => 1, + CompletionItemType::Method + | CompletionItemType::Function + | CompletionItemType::Constructor => 2, + CompletionItemType::Class + | CompletionItemType::Interface + | CompletionItemType::Module => 3, + _ => 4, + } + } + + fn completion_item_sort_text(completion_item: &CompletionItem) -> String { + completion_item.sort_text.clone().unwrap_or_else(|| { + format!( + "{:03}_{}", + Self::completion_type_score( + completion_item.type_.unwrap_or(CompletionItemType::Text) + ), + completion_item.label.to_ascii_lowercase() + ) + }) + } + fn client_completions( &self, console: &Entity, @@ -726,34 +757,25 @@ impl ConsoleQueryBarCompletionProvider { cx.background_executor().spawn(async move { let completions = completion_task.await?; + let buffer_text = snapshot.text(); + let completions = completions .into_iter() .map(|completion| { + let sort_text = Self::completion_item_sort_text(&completion); let new_text = completion .text .as_ref() .unwrap_or(&completion.label) .to_owned(); - let buffer_text = snapshot.text(); - let buffer_bytes = buffer_text.as_bytes(); - let new_bytes = new_text.as_bytes(); - - let mut prefix_len = 0; - for i in (0..new_bytes.len()).rev() { - if buffer_bytes.ends_with(&new_bytes[0..i]) { - prefix_len = i; - break; - } - } - - let buffer_offset = buffer_position.to_offset(&snapshot); - let start = buffer_offset - prefix_len; - let start = snapshot.clip_offset(start, Bias::Left); - let start = snapshot.anchor_before(start); - let replace_range = start..buffer_position; project::Completion { - replace_range, + replace_range: Self::replace_range_for_completion( + &buffer_text, + buffer_position, + new_text.as_bytes(), + &snapshot, + ), new_text, label: CodeLabel { filter_range: 0..completion.label.len(), @@ -761,12 +783,11 @@ impl ConsoleQueryBarCompletionProvider { runs: Vec::new(), }, icon_path: None, - documentation: None, + documentation: completion.detail.map(|detail| { + CompletionDocumentation::MultiLineMarkdown(detail.into()) + }), confirm: None, - source: project::CompletionSource::BufferWord { - word_range: buffer_position..language::Anchor::MAX, - resolved: false, - }, + source: project::CompletionSource::Dap { sort_text }, insert_text_mode: None, } }) @@ -845,3 +866,145 @@ impl ansi::Handler for ConsoleHandler { } } } + +fn color_fetcher(color: ansi::Color) -> fn(&Theme) -> Hsla { + let color_fetcher: fn(&Theme) -> Hsla = match color { + // Named and theme defined colors + ansi::Color::Named(n) => match n { + ansi::NamedColor::Black => |theme| theme.colors().terminal_ansi_black, + ansi::NamedColor::Red => |theme| theme.colors().terminal_ansi_red, + ansi::NamedColor::Green => |theme| theme.colors().terminal_ansi_green, + ansi::NamedColor::Yellow => |theme| theme.colors().terminal_ansi_yellow, + ansi::NamedColor::Blue => |theme| theme.colors().terminal_ansi_blue, + ansi::NamedColor::Magenta => |theme| theme.colors().terminal_ansi_magenta, + ansi::NamedColor::Cyan => |theme| theme.colors().terminal_ansi_cyan, + ansi::NamedColor::White => |theme| theme.colors().terminal_ansi_white, + ansi::NamedColor::BrightBlack => |theme| theme.colors().terminal_ansi_bright_black, + ansi::NamedColor::BrightRed => |theme| theme.colors().terminal_ansi_bright_red, + ansi::NamedColor::BrightGreen => |theme| theme.colors().terminal_ansi_bright_green, + ansi::NamedColor::BrightYellow => |theme| theme.colors().terminal_ansi_bright_yellow, + ansi::NamedColor::BrightBlue => |theme| theme.colors().terminal_ansi_bright_blue, + ansi::NamedColor::BrightMagenta => |theme| theme.colors().terminal_ansi_bright_magenta, + ansi::NamedColor::BrightCyan => |theme| theme.colors().terminal_ansi_bright_cyan, + ansi::NamedColor::BrightWhite => |theme| theme.colors().terminal_ansi_bright_white, + ansi::NamedColor::Foreground => |theme| theme.colors().terminal_foreground, + ansi::NamedColor::Background => |theme| theme.colors().terminal_background, + ansi::NamedColor::Cursor => |theme| theme.players().local().cursor, + ansi::NamedColor::DimBlack => |theme| theme.colors().terminal_ansi_dim_black, + ansi::NamedColor::DimRed => |theme| theme.colors().terminal_ansi_dim_red, + ansi::NamedColor::DimGreen => |theme| theme.colors().terminal_ansi_dim_green, + ansi::NamedColor::DimYellow => |theme| theme.colors().terminal_ansi_dim_yellow, + ansi::NamedColor::DimBlue => |theme| theme.colors().terminal_ansi_dim_blue, + ansi::NamedColor::DimMagenta => |theme| theme.colors().terminal_ansi_dim_magenta, + ansi::NamedColor::DimCyan => |theme| theme.colors().terminal_ansi_dim_cyan, + ansi::NamedColor::DimWhite => |theme| theme.colors().terminal_ansi_dim_white, + ansi::NamedColor::BrightForeground => |theme| theme.colors().terminal_bright_foreground, + ansi::NamedColor::DimForeground => |theme| theme.colors().terminal_dim_foreground, + }, + // 'True' colors + ansi::Color::Spec(_) => |theme| theme.colors().editor_background, + // 8 bit, indexed colors + ansi::Color::Indexed(i) => { + match i { + // 0-15 are the same as the named colors above + 0 => |theme| theme.colors().terminal_ansi_black, + 1 => |theme| theme.colors().terminal_ansi_red, + 2 => |theme| theme.colors().terminal_ansi_green, + 3 => |theme| theme.colors().terminal_ansi_yellow, + 4 => |theme| theme.colors().terminal_ansi_blue, + 5 => |theme| theme.colors().terminal_ansi_magenta, + 6 => |theme| theme.colors().terminal_ansi_cyan, + 7 => |theme| theme.colors().terminal_ansi_white, + 8 => |theme| theme.colors().terminal_ansi_bright_black, + 9 => |theme| theme.colors().terminal_ansi_bright_red, + 10 => |theme| theme.colors().terminal_ansi_bright_green, + 11 => |theme| theme.colors().terminal_ansi_bright_yellow, + 12 => |theme| theme.colors().terminal_ansi_bright_blue, + 13 => |theme| theme.colors().terminal_ansi_bright_magenta, + 14 => |theme| theme.colors().terminal_ansi_bright_cyan, + 15 => |theme| theme.colors().terminal_ansi_bright_white, + // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm. + // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl + // 16..=231 => { + // let (r, g, b) = rgb_for_index(index as u8); + // rgba_color( + // if r == 0 { 0 } else { r * 40 + 55 }, + // if g == 0 { 0 } else { g * 40 + 55 }, + // if b == 0 { 0 } else { b * 40 + 55 }, + // ) + // } + // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238). + // 232..=255 => { + // let i = index as u8 - 232; // Align index to 0..24 + // let value = i * 10 + 8; + // rgba_color(value, value, value) + // } + // For compatibility with the alacritty::Colors interface + // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs + _ => |_| gpui::black(), + } + } + }; + color_fetcher +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::init_test; + use editor::test::editor_test_context::EditorTestContext; + use gpui::TestAppContext; + use language::Point; + + #[track_caller] + fn assert_completion_range( + input: &str, + expect: &str, + replacement: &str, + cx: &mut EditorTestContext, + ) { + cx.set_state(input); + + let buffer_position = + cx.editor(|editor, _, cx| editor.selections.newest::(cx).start); + + let snapshot = &cx.buffer_snapshot(); + + let replace_range = ConsoleQueryBarCompletionProvider::replace_range_for_completion( + &cx.buffer_text(), + snapshot.anchor_before(buffer_position), + replacement.as_bytes(), + &snapshot, + ); + + cx.update_editor(|editor, _, cx| { + editor.edit( + vec![( + snapshot.offset_for_anchor(&replace_range.start) + ..snapshot.offset_for_anchor(&replace_range.end), + replacement, + )], + cx, + ); + }); + + pretty_assertions::assert_eq!(expect, cx.display_text()); + } + + #[gpui::test] + async fn test_determine_completion_replace_range(cx: &mut TestAppContext) { + init_test(cx); + + let mut cx = EditorTestContext::new(cx).await; + + assert_completion_range("resˇ", "result", "result", &mut cx); + assert_completion_range("print(resˇ)", "print(result)", "result", &mut cx); + assert_completion_range("$author->nˇ", "$author->name", "$author->name", &mut cx); + assert_completion_range( + "$author->books[ˇ", + "$author->books[0]", + "$author->books[0]", + &mut cx, + ); + } +} diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..9d946449544ecfd1a9c91c11918aaf1becb3d4d0 --- /dev/null +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -0,0 +1,963 @@ +use std::{ + cell::LazyCell, + fmt::Write, + ops::RangeInclusive, + sync::{Arc, LazyLock}, + time::Duration, +}; + +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{ + Action, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, MouseButton, + MouseMoveEvent, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, + TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, bounds, + deferred, point, size, uniform_list, +}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; +use settings::Settings; +use theme::ThemeSettings; +use ui::{ + ActiveTheme, AnyElement, App, Color, Context, ContextMenu, Div, Divider, DropdownMenu, Element, + FluentBuilder, Icon, IconName, InteractiveElement, IntoElement, Label, LabelCommon, + ParentElement, Pixels, PopoverMenuHandle, Render, Scrollbar, ScrollbarState, SharedString, + StatefulInteractiveElement, Styled, TextSize, Tooltip, Window, div, h_flex, px, v_flex, +}; +use util::ResultExt; +use workspace::Workspace; + +use crate::{ToggleDataBreakpoint, session::running::stack_frame_list::StackFrameList}; + +actions!(debugger, [GoToSelectedAddress]); + +pub(crate) struct MemoryView { + workspace: WeakEntity, + scroll_handle: UniformListScrollHandle, + scroll_state: ScrollbarState, + show_scrollbar: bool, + stack_frame_list: WeakEntity, + hide_scrollbar_task: Option>, + focus_handle: FocusHandle, + view_state: ViewState, + query_editor: Entity, + session: Entity, + width_picker_handle: PopoverMenuHandle, + is_writing_memory: bool, + open_context_menu: Option<(Entity, Point, Subscription)>, +} + +impl Focusable for MemoryView { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} +#[derive(Clone, Debug)] +struct Drag { + start_address: u64, + end_address: u64, +} + +impl Drag { + fn contains(&self, address: u64) -> bool { + let range = self.memory_range(); + range.contains(&address) + } + + fn memory_range(&self) -> RangeInclusive { + if self.start_address < self.end_address { + self.start_address..=self.end_address + } else { + self.end_address..=self.start_address + } + } +} +#[derive(Clone, Debug)] +enum SelectedMemoryRange { + DragUnderway(Drag), + DragComplete(Drag), +} + +impl SelectedMemoryRange { + fn contains(&self, address: u64) -> bool { + match self { + SelectedMemoryRange::DragUnderway(drag) => drag.contains(address), + SelectedMemoryRange::DragComplete(drag) => drag.contains(address), + } + } + fn is_dragging(&self) -> bool { + matches!(self, SelectedMemoryRange::DragUnderway(_)) + } + fn drag(&self) -> &Drag { + match self { + SelectedMemoryRange::DragUnderway(drag) => drag, + SelectedMemoryRange::DragComplete(drag) => drag, + } + } +} + +#[derive(Clone)] +struct ViewState { + /// Uppermost row index + base_row: u64, + /// How many cells per row do we have? + line_width: ViewWidth, + selection: Option, +} + +impl ViewState { + fn new(base_row: u64, line_width: ViewWidth) -> Self { + Self { + base_row, + line_width, + selection: None, + } + } + fn row_count(&self) -> u64 { + // This was picked fully arbitrarily. There's no incentive for us to care about page sizes other than the fact that it seems to be a good + // middle ground for data size. + const PAGE_SIZE: u64 = 4096; + PAGE_SIZE / self.line_width.width as u64 + } + fn schedule_scroll_down(&mut self) { + self.base_row = self.base_row.saturating_add(1) + } + fn schedule_scroll_up(&mut self) { + self.base_row = self.base_row.saturating_sub(1); + } +} + +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, + workspace: WeakEntity, + stack_frame_list: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let view_state = ViewState::new(0, WIDTHS[4].clone()); + let scroll_handle = UniformListScrollHandle::default(); + + 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, + show_scrollbar: false, + hide_scrollbar_task: None, + focus_handle: cx.focus_handle(), + view_state, + query_editor, + session, + width_picker_handle: Default::default(), + is_writing_memory: true, + open_context_menu: None, + }; + this.change_query_bar_mode(false, window, cx); + this + } + fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { + const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); + 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 render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { + if !(self.show_scrollbar || self.scroll_state.is_dragging()) { + return None; + } + Some( + div() + .occlude() + .id("memory-view-vertical-scrollbar") + .on_mouse_move(cx.listener(|this, evt, _, cx| { + this.handle_drag(evt); + 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.scroll_state.clone())), + ) + } + + 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(); + uniform_list( + "debugger-memory-view", + self.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 = + (view_state.base_row + range.start as u64) * view_state.line_width.width as u64; + let memory_end = (view_state.base_row + range.end as u64) + * view_state.line_width.width as u64 + - 1; + let mut memory = session.update(cx, |this, cx| { + this.read_memory(memory_start..=memory_end, cx) + }); + let mut rows = Vec::with_capacity(range.end - range.start); + for ix in range { + line_buffer.extend((&mut memory).take(view_state.line_width.width as usize)); + rows.push(render_single_memory_view_line( + &line_buffer, + ix as u64, + weak.clone(), + cx, + )); + line_buffer.clear(); + } + rows + }, + ) + .track_scroll(self.scroll_handle.clone()) + .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| { + 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)); + })) + } + fn render_query_bar(&self, cx: &Context) -> impl IntoElement { + EditorElement::new( + &self.query_editor, + Self::editor_style(&self.query_editor, cx), + ) + } + pub(super) fn go_to_memory_reference( + &mut self, + memory_reference: &str, + evaluate_name: Option<&str>, + stack_frame_id: Option, + cx: &mut Context, + ) { + use parse_int::parse; + let Ok(as_address) = parse::(&memory_reference) else { + return; + }; + let access_size = evaluate_name + .map(|typ| { + self.session.update(cx, |this, cx| { + this.data_access_size(stack_frame_id, typ, cx) + }) + }) + .unwrap_or_else(|| Task::ready(None)); + 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 { + start_address: as_address, + end_address: as_address + access_size - 1, + })); + this.jump_to_address(as_address, cx); + }) + .ok(); + }) + .detach(); + } + + fn handle_drag(&mut self, evt: &MouseMoveEvent) { + if !evt.dragging() { + return; + } + if !self.scroll_state.is_dragging() + && !self + .view_state + .selection + .as_ref() + .is_some_and(|selection| selection.is_dragging()) + { + return; + } + 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(); + let (top_area, bottom_area) = { + let size = size(viewport.size.width, viewport.size.height / 10.); + ( + bounds(viewport.origin, size), + bounds( + point(viewport.origin.x, viewport.origin.y + size.height * 2.), + size, + ), + ) + }; + + if bottom_area.contains(&evt.position) { + //ix == row_count - 1 { + self.view_state.schedule_scroll_down(); + } else if top_area.contains(&evt.position) { + self.view_state.schedule_scroll_up(); + } + } + + fn editor_style(editor: &Entity, cx: &Context) -> EditorStyle { + let is_read_only = editor.read(cx).read_only(cx); + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme(); + let text_style = TextStyle { + color: if is_read_only { + theme.colors().text_muted + } else { + theme.colors().text + }, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: TextSize::Small.rems(cx).into(), + font_weight: settings.buffer_font.weight, + + ..Default::default() + }; + EditorStyle { + background: theme.colors().editor_background, + local_player: theme.players().local(), + text: text_style, + ..Default::default() + } + } + + 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(); + DropdownMenu::new( + "memory-view-width-picker", + selected_width.label.clone(), + ContextMenu::build(window, cx, |mut this, window, cx| { + for width in &WIDTHS { + let weak = weak.clone(); + let width = width.clone(); + this = this.entry(width.label.clone(), None, move |_, cx| { + _ = weak.update(cx, |this, _| { + // 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) { + 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; + } + std::cmp::Ordering::Greater => { + // We're converting down. + let shift = this.view_state.line_width.width.trailing_zeros() + - width.width.trailing_zeros(); + this.view_state.base_row <<= shift; + } + _ => {} + } + this.view_state.line_width = width.clone(); + }); + }); + } + if let Some(ix) = WIDTHS + .iter() + .position(|width| width.width == selected_width.width) + { + for _ in 0..=ix { + this.select_next(&Default::default(), window, cx); + } + } + this + }), + ) + .handle(self.width_picker_handle.clone()) + } + + fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { + self.view_state.base_row = self + .view_state + .base_row + .overflowing_add(self.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 + .base_row + .overflowing_sub(self.view_state.row_count()) + .0; + cx.notify(); + } + + fn change_query_bar_mode( + &mut self, + is_writing_memory: bool, + window: &mut Window, + cx: &mut Context, + ) { + if is_writing_memory == self.is_writing_memory { + return; + } + 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); + }); + 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); + }); + self.is_writing_memory = false; + } + } + + fn toggle_data_breakpoint( + &mut self, + _: &crate::ToggleDataBreakpoint, + _: &mut Window, + cx: &mut Context, + ) { + let Some(SelectedMemoryRange::DragComplete(selection)) = self.view_state.selection.clone() + else { + return; + }; + let range = selection.memory_range(); + let context = Arc::new(DataBreakpointContext::Address { + address: range.start().to_string(), + bytes: Some(*range.end() - *range.start()), + }); + + self.session.update(cx, |this, cx| { + 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 { + return; + }; + _ = this.update(cx, |this, cx| { + this.create_data_breakpoint( + context, + data_id.clone(), + dap::DataBreakpoint { + data_id, + access_type: None, + condition: None, + hit_condition: None, + }, + cx, + ); + }); + } + }) + .detach(); + }) + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + if let Some(SelectedMemoryRange::DragComplete(drag)) = &self.view_state.selection { + // Go into memory writing mode. + if !self.is_writing_memory { + let should_return = self.session.update(cx, |session, cx| { + if !session + .capabilities() + .supports_write_memory_request + .unwrap_or_default() + { + let adapter_name = session.adapter(); + // We cannot write memory with this adapter. + _ = self.workspace.update(cx, |this, cx| { + this.toggle_status_toast( + StatusToast::new(format!( + "Debug Adapter `{adapter_name}` does not support writing to memory" + ), cx, |this, cx| { + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + _ = this.update(cx, |_, cx| { + cx.emit(DismissEvent) + }); + }).detach(); + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + }), + cx, + ); + }); + true + } else { + false + } + }); + if should_return { + return; + } + + self.change_query_bar_mode(true, window, cx); + } else if self.query_editor.focus_handle(cx).is_focused(window) { + let mut text = self.query_editor.read(cx).text(cx); + if text.chars().any(|c| !c.is_ascii_hexdigit()) { + // Interpret this text as a string and oh-so-conveniently convert it. + text = text.bytes().map(|byte| format!("{:02x}", byte)).collect(); + } + self.session.update(cx, |this, cx| { + let range = drag.memory_range(); + + if let Ok(as_hex) = hex::decode(text) { + this.write_memory(*range.start(), &as_hex, cx); + } + }); + self.change_query_bar_mode(false, window, cx); + } + + cx.notify(); + return; + } + // Just change the currently viewed address. + if !self.query_editor.focus_handle(cx).is_focused(window) { + return; + } + self.jump_to_query_bar_address(cx); + } + + fn jump_to_query_bar_address(&mut self, cx: &mut Context) { + use parse_int::parse; + let text = self.query_editor.read(cx).text(cx); + + let Ok(as_address) = parse::(&text) else { + return self.jump_to_expression(text, cx); + }; + self.jump_to_address(as_address, cx); + } + + 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 + .scroll_to_item(line_ix as usize, ScrollStrategy::Center); + cx.notify(); + } + + fn jump_to_expression(&mut self, expr: String, cx: &mut Context) { + let Ok(selected_frame) = self + .stack_frame_list + .update(cx, |this, _| this.opened_stack_frame_id()) + else { + return; + }; + let reference = self.session.update(cx, |this, cx| { + this.memory_reference_of_expr(selected_frame, expr, cx) + }); + cx.spawn(async move |this, cx| { + if let Some(reference) = reference.await { + _ = this.update(cx, |this, cx| { + let Ok(address) = parse_int::parse::(&reference) else { + return; + }; + this.jump_to_address(address, cx); + }); + } + }) + .detach(); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + self.view_state.selection = None; + cx.notify(); + } + + /// Jump to memory pointed to by selected memory range. + fn go_to_address( + &mut self, + _: &GoToSelectedAddress, + window: &mut Window, + cx: &mut Context, + ) { + let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state.selection.clone() + else { + return; + }; + let range = drag.memory_range(); + let Some(memory): Option> = self.session.update(cx, |this, cx| { + this.read_memory(range, cx).map(|cell| cell.0).collect() + }) else { + return; + }; + if memory.len() > 8 { + return; + } + let zeros_to_write = 8 - memory.len(); + let mut acc = String::from("0x"); + acc.extend(std::iter::repeat("00").take(zeros_to_write)); + let as_query = memory.into_iter().rev().fold(acc, |mut acc, byte| { + _ = write!(&mut acc, "{:02x}", byte); + acc + }); + self.query_editor.update(cx, |this, cx| { + this.set_text(as_query, window, cx); + }); + self.jump_to_query_bar_address(cx); + } + + fn deploy_memory_context_menu( + &mut self, + range: RangeInclusive, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let session = self.session.clone(); + let context_menu = ContextMenu::build(window, cx, |menu, _, cx| { + let range_too_large = range.end() - range.start() > std::mem::size_of::() as u64; + let caps = session.read(cx).capabilities(); + let supports_data_breakpoints = caps.supports_data_breakpoints.unwrap_or_default() + && caps.supports_data_breakpoint_bytes.unwrap_or_default(); + let memory_unreadable = LazyCell::new(|| { + session.update(cx, |this, cx| { + this.read_memory(range.clone(), cx) + .any(|cell| cell.0.is_none()) + }) + }); + + let mut menu = menu.action_disabled_when( + range_too_large || *memory_unreadable, + "Go To Selected Address", + GoToSelectedAddress.boxed_clone(), + ); + + if supports_data_breakpoints { + menu = menu.action_disabled_when( + *memory_unreadable, + "Set Data Breakpoint", + ToggleDataBreakpoint.boxed_clone(), + ); + } + menu.context(self.focus_handle.clone()) + }); + + cx.focus_view(&context_menu, window); + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.open_context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.open_context_menu.take(); + cx.notify(); + }, + ); + + self.open_context_menu = Some((context_menu, position, subscription)); + } +} + +#[derive(Clone)] +struct ViewWidth { + width: u8, + label: SharedString, +} + +impl ViewWidth { + const fn new(width: u8, label: &'static str) -> Self { + Self { + width, + label: SharedString::new_static(label), + } + } +} + +static WIDTHS: [ViewWidth; 7] = [ + ViewWidth::new(1, "1 byte"), + ViewWidth::new(2, "2 bytes"), + ViewWidth::new(4, "4 bytes"), + ViewWidth::new(8, "8 bytes"), + ViewWidth::new(16, "16 bytes"), + ViewWidth::new(32, "32 bytes"), + ViewWidth::new(64, "64 bytes"), +]; + +fn render_single_memory_view_line( + memory: &[MemoryCell], + ix: u64, + weak: gpui::WeakEntity, + cx: &mut App, +) -> AnyElement { + 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; + + h_flex() + .id(( + "memory-view-row-full", + ix * view_state.line_width.width as u64, + )) + .size_full() + .gap_x_2() + .child( + div() + .child( + Label::new(format!("{:016X}", base_address)) + .buffer_font(cx) + .size(ui::LabelSize::Small) + .color(Color::Muted), + ) + .px_1() + .border_r_1() + .border_color(Color::Muted.color(cx)), + ) + .child( + h_flex() + .id(( + "memory-view-row-raw-memory", + ix * view_state.line_width.width as u64, + )) + .px_1() + .children(memory.iter().enumerate().map(|(cell_ix, cell)| { + let weak = weak.clone(); + div() + .id(("memory-view-row-raw-memory-cell", cell_ix as u64)) + .px_0p5() + .when_some(view_state.selection.as_ref(), |this, selection| { + this.when(selection.contains(base_address + cell_ix as u64), |this| { + let weak = weak.clone(); + + this.bg(Color::Accent.color(cx)).when( + !selection.is_dragging(), + |this| { + let selection = selection.drag().memory_range(); + this.on_mouse_down( + MouseButton::Right, + move |click, window, cx| { + _ = weak.update(cx, |this, cx| { + this.deploy_memory_context_menu( + selection.clone(), + click.position, + window, + cx, + ) + }); + cx.stop_propagation(); + }, + ) + }, + ) + }) + }) + .child( + Label::new( + cell.0 + .map(|val| HEX_BYTES_MEMOIZED[val as usize].clone()) + .unwrap_or_else(|| UNKNOWN_BYTE.clone()), + ) + .buffer_font(cx) + .when(cell.0.is_none(), |this| this.color(Color::Muted)) + .size(ui::LabelSize::Small), + ) + .on_drag( + Drag { + start_address: base_address + cell_ix as u64, + end_address: base_address + cell_ix as u64, + }, + { + let weak = weak.clone(); + move |drag, _, _, cx| { + _ = weak.update(cx, |this, _| { + this.view_state.selection = + Some(SelectedMemoryRange::DragUnderway(drag.clone())); + }); + + cx.new(|_| Empty) + } + }, + ) + .on_drop({ + let weak = weak.clone(); + move |drag: &Drag, _, cx| { + _ = weak.update(cx, |this, _| { + this.view_state.selection = + Some(SelectedMemoryRange::DragComplete(Drag { + start_address: drag.start_address, + end_address: base_address + cell_ix as u64, + })); + }); + } + }) + .drag_over(move |style, drag: &Drag, _, cx| { + _ = weak.update(cx, |this, _| { + this.view_state.selection = + Some(SelectedMemoryRange::DragUnderway(Drag { + start_address: drag.start_address, + end_address: base_address + cell_ix as u64, + })); + }); + + style + }) + })), + ) + .child( + h_flex() + .id(( + "memory-view-row-ascii-memory", + ix * view_state.line_width.width as u64, + )) + .h_full() + .px_1() + .mr_4() + // .gap_x_1p5() + .border_x_1() + .border_color(Color::Muted.color(cx)) + .children(memory.iter().enumerate().map(|(ix, cell)| { + let as_character = char::from(cell.0.unwrap_or(0)); + let as_visible = if as_character.is_ascii_graphic() { + as_character + } else { + '·' + }; + div() + .px_0p5() + .when_some(view_state.selection.as_ref(), |this, selection| { + this.when(selection.contains(base_address + ix as u64), |this| { + this.bg(Color::Accent.color(cx)) + }) + }) + .child( + Label::new(format!("{as_visible}")) + .buffer_font(cx) + .when(cell.0.is_none(), |this| this.color(Color::Muted)) + .size(ui::LabelSize::Small), + ) + })), + ) + .into_any() +} + +impl Render for MemoryView { + fn render( + &mut self, + window: &mut ui::Window, + cx: &mut ui::Context, + ) -> impl ui::IntoElement { + let (icon, tooltip_text) = if self.is_writing_memory { + (IconName::Pencil, "Edit memory at a selected address") + } else { + ( + IconName::LocationEdit, + "Change address of currently viewed memory", + ) + }; + v_flex() + .id("Memory-view") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::go_to_address)) + .p_1() + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::toggle_data_breakpoint)) + .on_action(cx.listener(Self::page_down)) + .on_action(cx.listener(Self::page_up)) + .size_full() + .track_focus(&self.focus_handle) + .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); + } + })) + .child( + h_flex() + .w_full() + .mb_0p5() + .gap_1() + .child( + h_flex() + .w_full() + .rounded_md() + .border_1() + .gap_x_2() + .px_2() + .py_0p5() + .mb_0p5() + .bg(cx.theme().colors().editor_background) + .when_else( + self.query_editor + .focus_handle(cx) + .contains_focused(window, cx), + |this| this.border_color(cx.theme().colors().border_focused), + |this| this.border_color(cx.theme().colors().border_transparent), + ) + .child( + div() + .id("memory-view-editor-icon") + .child(Icon::new(icon).size(ui::IconSize::XSmall)) + .tooltip(Tooltip::text(tooltip_text)), + ) + .child(self.render_query_bar(cx)), + ) + .child(self.render_width_picker(window, cx)), + ) + .child(Divider::horizontal()) + .child( + v_flex() + .size_full() + .on_mouse_move(cx.listener(|this, evt: &MouseMoveEvent, _, _| { + this.handle_drag(evt); + })) + .child(self.render_memory(cx).size_full()) + .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Corner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + .children(self.render_vertical_scrollbar(cx)), + ) + } +} diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index c58ac865f9c5ed23e3b8129666ca7006408a34bc..b158314b507317227a6b94c8916c7a2c125f2380 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -1,3 +1,5 @@ +use crate::session::running::{RunningState, memory_view::MemoryView}; + use super::stack_frame_list::{StackFrameList, StackFrameListEvent}; use dap::{ ScopePresentationHint, StackFrameId, VariablePresentationHint, VariablePresentationHintKind, @@ -7,24 +9,37 @@ use editor::Editor; use gpui::{ Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription, - TextStyleRefinement, UniformListScrollHandle, actions, anchored, deferred, uniform_list, + TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, + uniform_list, }; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::debugger::session::{Session, SessionEvent, Watcher}; +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 util::debug_panic; +use util::{debug_panic, maybe}; actions!( variable_list, [ + /// Expands the selected variable entry to show its children. ExpandSelectedEntry, + /// Collapses the selected variable entry to hide its children. CollapseSelectedEntry, + /// Copies the variable name to the clipboard. CopyVariableName, + /// Copies the variable value to the clipboard. CopyVariableValue, + /// Edits the value of the selected variable. EditVariable, + /// Adds the selected variable to the watch list. AddWatch, + /// Removes the selected variable from the watch list. RemoveWatch, + /// Jump to variable's memory location. + GoToMemory, ] ); @@ -79,30 +94,30 @@ impl EntryPath { } #[derive(Debug, Clone, PartialEq)] -enum EntryKind { +enum DapEntry { Watcher(Watcher), Variable(dap::Variable), Scope(dap::Scope), } -impl EntryKind { +impl DapEntry { fn as_watcher(&self) -> Option<&Watcher> { match self { - EntryKind::Watcher(watcher) => Some(watcher), + DapEntry::Watcher(watcher) => Some(watcher), _ => None, } } fn as_variable(&self) -> Option<&dap::Variable> { match self { - EntryKind::Variable(dap) => Some(dap), + DapEntry::Variable(dap) => Some(dap), _ => None, } } fn as_scope(&self) -> Option<&dap::Scope> { match self { - EntryKind::Scope(dap) => Some(dap), + DapEntry::Scope(dap) => Some(dap), _ => None, } } @@ -110,38 +125,38 @@ impl EntryKind { #[cfg(test)] fn name(&self) -> &str { match self { - EntryKind::Watcher(watcher) => &watcher.expression, - EntryKind::Variable(dap) => &dap.name, - EntryKind::Scope(dap) => &dap.name, + DapEntry::Watcher(watcher) => &watcher.expression, + DapEntry::Variable(dap) => &dap.name, + DapEntry::Scope(dap) => &dap.name, } } } #[derive(Debug, Clone, PartialEq)] struct ListEntry { - dap_kind: EntryKind, + entry: DapEntry, path: EntryPath, } impl ListEntry { fn as_watcher(&self) -> Option<&Watcher> { - self.dap_kind.as_watcher() + self.entry.as_watcher() } fn as_variable(&self) -> Option<&dap::Variable> { - self.dap_kind.as_variable() + self.entry.as_variable() } fn as_scope(&self) -> Option<&dap::Scope> { - self.dap_kind.as_scope() + self.entry.as_scope() } fn item_id(&self) -> ElementId { use std::fmt::Write; - let mut id = match &self.dap_kind { - EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression), - EntryKind::Variable(dap) => format!("variable-{}", dap.name), - EntryKind::Scope(dap) => format!("scope-{}", dap.name), + let mut id = match &self.entry { + DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression), + DapEntry::Variable(dap) => format!("variable-{}", dap.name), + DapEntry::Scope(dap) => format!("scope-{}", dap.name), }; for name in self.path.indices.iter() { _ = write!(id, "-{}", name); @@ -151,10 +166,10 @@ impl ListEntry { fn item_value_id(&self) -> ElementId { use std::fmt::Write; - let mut id = match &self.dap_kind { - EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression), - EntryKind::Variable(dap) => format!("variable-{}", dap.name), - EntryKind::Scope(dap) => format!("scope-{}", dap.name), + let mut id = match &self.entry { + DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression), + DapEntry::Variable(dap) => format!("variable-{}", dap.name), + DapEntry::Scope(dap) => format!("scope-{}", dap.name), }; for name in self.path.indices.iter() { _ = write!(id, "-{}", name); @@ -181,13 +196,17 @@ pub struct VariableList { focus_handle: FocusHandle, edited_path: Option<(EntryPath, Entity)>, disabled: bool, + memory_view: Entity, + weak_running: WeakEntity, _subscriptions: Vec, } impl VariableList { - pub fn new( + pub(crate) fn new( session: Entity, stack_frame_list: Entity, + memory_view: Entity, + weak_running: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -204,6 +223,7 @@ impl VariableList { SessionEvent::Variables | SessionEvent::Watchers => { this.build_entries(cx); } + _ => {} }), cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { @@ -227,6 +247,8 @@ impl VariableList { edited_path: None, entries: Default::default(), entry_states: Default::default(), + weak_running, + memory_view, } } @@ -277,7 +299,7 @@ impl VariableList { scope.variables_reference, scope.variables_reference, EntryPath::for_scope(&scope.name), - EntryKind::Scope(scope), + DapEntry::Scope(scope), ) }) .collect::>(); @@ -291,7 +313,7 @@ impl VariableList { watcher.variables_reference, watcher.variables_reference, EntryPath::for_watcher(watcher.expression.clone()), - EntryKind::Watcher(watcher.clone()), + DapEntry::Watcher(watcher.clone()), ) }) .collect::>(), @@ -302,9 +324,9 @@ impl VariableList { while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop() { match &dap_kind { - EntryKind::Watcher(watcher) => path = path.with_child(watcher.expression.clone()), - EntryKind::Variable(dap) => path = path.with_name(dap.name.clone().into()), - EntryKind::Scope(dap) => path = path.with_child(dap.name.clone().into()), + DapEntry::Watcher(watcher) => path = path.with_child(watcher.expression.clone()), + DapEntry::Variable(dap) => path = path.with_name(dap.name.clone().into()), + DapEntry::Scope(dap) => path = path.with_child(dap.name.clone().into()), } let var_state = self @@ -329,7 +351,7 @@ impl VariableList { }); entries.push(ListEntry { - dap_kind, + entry: dap_kind, path: path.clone(), }); @@ -342,7 +364,7 @@ impl VariableList { variables_reference, child.variables_reference, path.with_child(child.name.clone().into()), - EntryKind::Variable(child), + DapEntry::Variable(child), ) })); } @@ -373,9 +395,9 @@ impl VariableList { pub fn completion_variables(&self, _cx: &mut Context) -> Vec { self.entries .iter() - .filter_map(|entry| match &entry.dap_kind { - EntryKind::Variable(dap) => Some(dap.clone()), - EntryKind::Scope(_) | EntryKind::Watcher { .. } => None, + .filter_map(|entry| match &entry.entry { + DapEntry::Variable(dap) => Some(dap.clone()), + DapEntry::Scope(_) | DapEntry::Watcher { .. } => None, }) .collect() } @@ -393,12 +415,12 @@ impl VariableList { .get(ix) .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?; - match &entry.dap_kind { - EntryKind::Watcher { .. } => { + match &entry.entry { + DapEntry::Watcher { .. } => { Some(self.render_watcher(entry, *state, window, cx)) } - EntryKind::Variable(_) => Some(self.render_variable(entry, *state, window, cx)), - EntryKind::Scope(_) => Some(self.render_scope(entry, *state, cx)), + DapEntry::Variable(_) => Some(self.render_variable(entry, *state, window, cx)), + DapEntry::Scope(_) => Some(self.render_scope(entry, *state, cx)), } }) .collect() @@ -555,6 +577,51 @@ impl VariableList { } } + fn jump_to_variable_memory( + &mut self, + _: &GoToMemory, + window: &mut Window, + cx: &mut Context, + ) { + _ = maybe!({ + let selection = self.selection.as_ref()?; + let entry = self.entries.iter().find(|entry| &entry.path == selection)?; + let var = entry.entry.as_variable()?; + let memory_reference = var.memory_reference.as_deref()?; + + let sizeof_expr = if var.type_.as_ref().is_some_and(|t| { + t.chars() + .all(|c| c.is_whitespace() || c.is_alphabetic() || c == '*') + }) { + var.type_.as_deref() + } else { + var.evaluate_name + .as_deref() + .map(|name| name.strip_prefix("/nat ").unwrap_or_else(|| name)) + }; + self.memory_view.update(cx, |this, cx| { + this.go_to_memory_reference( + memory_reference, + sizeof_expr, + self.selected_stack_frame_id, + cx, + ); + }); + let weak_panel = self.weak_running.clone(); + + window.defer(cx, move |window, cx| { + _ = weak_panel.update(cx, |this, cx| { + this.activate_item( + crate::persistence::DebuggerPaneItem::MemoryView, + window, + cx, + ); + }); + }); + Some(()) + }); + } + fn deploy_list_entry_context_menu( &mut self, entry: ListEntry, @@ -562,49 +629,156 @@ impl VariableList { window: &mut Window, cx: &mut Context, ) { - let supports_set_variable = self - .session - .read(cx) - .capabilities() - .supports_set_variable - .unwrap_or_default(); - - let context_menu = ContextMenu::build(window, cx, |menu, _, _| { - menu.when(entry.as_variable().is_some(), |menu| { - menu.action("Copy Name", CopyVariableName.boxed_clone()) - .action("Copy Value", CopyVariableValue.boxed_clone()) - .when(supports_set_variable, |menu| { - menu.action("Edit Value", EditVariable.boxed_clone()) + let (supports_set_variable, supports_data_breakpoints, supports_go_to_memory) = + self.session.read_with(cx, |session, _| { + ( + session + .capabilities() + .supports_set_variable + .unwrap_or_default(), + session + .capabilities() + .supports_data_breakpoints + .unwrap_or_default(), + session + .capabilities() + .supports_read_memory_request + .unwrap_or_default(), + ) + }); + let can_toggle_data_breakpoint = entry + .as_variable() + .filter(|_| supports_data_breakpoints) + .and_then(|variable| { + let variables_reference = self + .entry_states + .get(&entry.path) + .map(|state| state.parent_reference)?; + Some(self.session.update(cx, |session, cx| { + session.data_breakpoint_info( + Arc::new(DataBreakpointContext::Variable { + variables_reference, + name: variable.name.clone(), + bytes: None, + }), + None, + cx, + ) + })) + }); + + let focus_handle = self.focus_handle.clone(); + cx.spawn_in(window, async move |this, cx| { + let can_toggle_data_breakpoint = if let Some(task) = can_toggle_data_breakpoint { + task.await.is_some() + } else { + true + }; + cx.update(|window, cx| { + let context_menu = ContextMenu::build(window, cx, |menu, _, _| { + menu.when_some(entry.as_variable(), |menu, _| { + menu.action("Copy Name", CopyVariableName.boxed_clone()) + .action("Copy Value", CopyVariableValue.boxed_clone()) + .when(supports_set_variable, |menu| { + menu.action("Edit Value", EditVariable.boxed_clone()) + }) + .when(supports_go_to_memory, |menu| { + menu.action("Go To Memory", GoToMemory.boxed_clone()) + }) + .action("Watch Variable", AddWatch.boxed_clone()) + .when(can_toggle_data_breakpoint, |menu| { + menu.action( + "Toggle Data Breakpoint", + crate::ToggleDataBreakpoint.boxed_clone(), + ) + }) }) - .action("Watch Variable", AddWatch.boxed_clone()) - }) - .when(entry.as_watcher().is_some(), |menu| { - menu.action("Copy Name", CopyVariableName.boxed_clone()) - .action("Copy Value", CopyVariableValue.boxed_clone()) - .when(supports_set_variable, |menu| { - menu.action("Edit Value", EditVariable.boxed_clone()) + .when(entry.as_watcher().is_some(), |menu| { + menu.action("Copy Name", CopyVariableName.boxed_clone()) + .action("Copy Value", CopyVariableValue.boxed_clone()) + .when(supports_set_variable, |menu| { + menu.action("Edit Value", EditVariable.boxed_clone()) + }) + .action("Remove Watch", RemoveWatch.boxed_clone()) }) - .action("Remove Watch", RemoveWatch.boxed_clone()) + .context(focus_handle.clone()) + }); + + _ = this.update(cx, |this, cx| { + cx.focus_view(&context_menu, window); + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.open_context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.open_context_menu.take(); + cx.notify(); + }, + ); + + this.open_context_menu = Some((context_menu, position, subscription)); + }); }) - .context(self.focus_handle.clone()) + }) + .detach(); + } + + fn toggle_data_breakpoint( + &mut self, + _: &crate::ToggleDataBreakpoint, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(entry) = self + .selection + .as_ref() + .and_then(|selection| self.entries.iter().find(|entry| &entry.path == selection)) + else { + return; + }; + + let Some((name, var_ref)) = entry.as_variable().map(|var| &var.name).zip( + self.entry_states + .get(&entry.path) + .map(|state| state.parent_reference), + ) else { + return; + }; + + let context = Arc::new(DataBreakpointContext::Variable { + variables_reference: var_ref, + name: name.clone(), + bytes: None, + }); + let data_breakpoint = self.session.update(cx, |session, cx| { + session.data_breakpoint_info(context.clone(), None, cx) }); - cx.focus_view(&context_menu, window); - let subscription = cx.subscribe_in( - &context_menu, - window, - |this, _, _: &DismissEvent, window, cx| { - if this.open_context_menu.as_ref().is_some_and(|context_menu| { - context_menu.0.focus_handle(cx).contains_focused(window, cx) - }) { - cx.focus_self(window); - } - this.open_context_menu.take(); + let session = self.session.downgrade(); + cx.spawn(async move |_, cx| { + let Some(data_id) = data_breakpoint.await.and_then(|info| info.data_id) else { + return; + }; + _ = session.update(cx, |session, cx| { + session.create_data_breakpoint( + context, + data_id.clone(), + dap::DataBreakpoint { + data_id, + access_type: None, + condition: None, + hit_condition: None, + }, + cx, + ); cx.notify(); - }, - ); - - self.open_context_menu = Some((context_menu, position, subscription)); + }); + }) + .detach(); } fn copy_variable_name( @@ -621,10 +795,10 @@ impl VariableList { return; }; - let variable_name = match &entry.dap_kind { - EntryKind::Variable(dap) => dap.name.clone(), - EntryKind::Watcher(watcher) => watcher.expression.to_string(), - EntryKind::Scope(_) => return, + let variable_name = match &entry.entry { + DapEntry::Variable(dap) => dap.name.clone(), + DapEntry::Watcher(watcher) => watcher.expression.to_string(), + DapEntry::Scope(_) => return, }; cx.write_to_clipboard(ClipboardItem::new_string(variable_name)); @@ -644,10 +818,10 @@ impl VariableList { return; }; - let variable_value = match &entry.dap_kind { - EntryKind::Variable(dap) => dap.value.clone(), - EntryKind::Watcher(watcher) => watcher.value.to_string(), - EntryKind::Scope(_) => return, + let variable_value = match &entry.entry { + DapEntry::Variable(dap) => dap.value.clone(), + DapEntry::Watcher(watcher) => watcher.value.to_string(), + DapEntry::Scope(_) => return, }; cx.write_to_clipboard(ClipboardItem::new_string(variable_value)); @@ -662,10 +836,10 @@ impl VariableList { return; }; - let variable_value = match &entry.dap_kind { - EntryKind::Watcher(watcher) => watcher.value.to_string(), - EntryKind::Variable(variable) => variable.value.clone(), - EntryKind::Scope(_) => return, + let variable_value = match &entry.entry { + DapEntry::Watcher(watcher) => watcher.value.to_string(), + DapEntry::Variable(variable) => variable.value.clone(), + DapEntry::Scope(_) => return, }; let editor = Self::create_variable_editor(&variable_value, window, cx); @@ -746,7 +920,7 @@ impl VariableList { "{}{} {}{}", INDENT.repeat(state.depth - 1), if state.is_expanded { "v" } else { ">" }, - entry.dap_kind.name(), + entry.entry.name(), if self.selection.as_ref() == Some(&entry.path) { " <=== selected" } else { @@ -763,8 +937,8 @@ impl VariableList { pub(crate) fn scopes(&self) -> Vec { self.entries .iter() - .filter_map(|entry| match &entry.dap_kind { - EntryKind::Scope(scope) => Some(scope), + .filter_map(|entry| match &entry.entry { + DapEntry::Scope(scope) => Some(scope), _ => None, }) .cloned() @@ -778,10 +952,10 @@ impl VariableList { let mut idx = 0; for entry in self.entries.iter() { - match &entry.dap_kind { - EntryKind::Watcher { .. } => continue, - EntryKind::Variable(dap) => scopes[idx].1.push(dap.clone()), - EntryKind::Scope(scope) => { + match &entry.entry { + DapEntry::Watcher { .. } => continue, + DapEntry::Variable(dap) => scopes[idx].1.push(dap.clone()), + DapEntry::Scope(scope) => { if scopes.len() > 0 { idx += 1; } @@ -799,8 +973,8 @@ impl VariableList { pub(crate) fn variables(&self) -> Vec { self.entries .iter() - .filter_map(|entry| match &entry.dap_kind { - EntryKind::Variable(variable) => Some(variable), + .filter_map(|entry| match &entry.entry { + DapEntry::Variable(variable) => Some(variable), _ => None, }) .cloned() @@ -1351,6 +1525,8 @@ impl Render for VariableList { .on_action(cx.listener(Self::edit_variable)) .on_action(cx.listener(Self::add_watcher)) .on_action(cx.listener(Self::remove_watcher)) + .on_action(cx.listener(Self::toggle_data_breakpoint)) + .on_action(cx.listener(Self::jump_to_variable_memory)) .child( uniform_list( "variable-list", diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index 139591530583b010c97ed8d2126a4ea210efe4c7..906a7a0d4bd76f0451d6b5d5cfa5beff0136c613 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -27,7 +27,7 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te let workspace = init_test_workspace(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); - let session = start_debug_session_with( + let _session = start_debug_session_with( &workspace, cx, DebugTaskDefinition { @@ -59,14 +59,6 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te assert!(workspace.active_modal::(cx).is_none()); }) .unwrap(); - - let shutdown_session = project.update(cx, |project, cx| { - project.dap_store().update(cx, |dap_store, cx| { - dap_store.shutdown_session(session.read(cx).session_id(), cx) - }) - }); - - shutdown_session.await.unwrap(); } #[gpui::test] diff --git a/crates/debugger_ui/src/tests/console.rs b/crates/debugger_ui/src/tests/console.rs index cae2ff3501e5313aa93b3a124a544238d41fc953..fad483b0f4af19826f9da0d32659c8ac83712f1f 100644 --- a/crates/debugger_ui/src/tests/console.rs +++ b/crates/debugger_ui/src/tests/console.rs @@ -232,7 +232,6 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test location_reference: None, })) .await; - // [crates/debugger_ui/src/session/running/console.rs:147:9] &to_insert = "Could not read source map for file:///Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js: ENOENT: no such file or directory, open '/Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js.map'\n" client .fake_event(dap::messages::Events::Output(dap::OutputEvent { category: None, @@ -260,7 +259,6 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test })) .await; - // introduce some background highlight client .fake_event(dap::messages::Events::Output(dap::OutputEvent { category: None, @@ -274,7 +272,6 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test location_reference: None, })) .await; - // another random line client .fake_event(dap::messages::Events::Output(dap::OutputEvent { category: None, @@ -294,6 +291,11 @@ async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut Test let _running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { cx.focus_self(window); + item.running_state().update(cx, |this, cx| { + this.console() + .update(cx, |this, cx| this.update_output(window, cx)); + }); + item.running_state().clone() }); diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 05bca8131ac9734b1635a90c22026424f1c5cf2e..505df09cfb2b47821cb59448801f014f923be8f1 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -427,7 +427,7 @@ async fn test_handle_start_debugging_request( let sessions = workspace .update(cx, |workspace, _window, cx| { let debug_panel = workspace.panel::(cx).unwrap(); - debug_panel.read(cx).sessions() + debug_panel.read(cx).sessions().collect::>() }) .unwrap(); assert_eq!(sessions.len(), 1); @@ -451,7 +451,7 @@ async fn test_handle_start_debugging_request( .unwrap() .read(cx) .session(cx); - let current_sessions = debug_panel.read(cx).sessions(); + let current_sessions = debug_panel.read(cx).sessions().collect::>(); assert_eq!(active_session, current_sessions[1].read(cx).session(cx)); assert_eq!( active_session.read(cx).parent_session(), @@ -1796,7 +1796,7 @@ async fn test_debug_adapters_shutdown_on_app_quit( let panel = workspace.panel::(cx).unwrap(); panel.read_with(cx, |panel, _| { assert!( - !panel.sessions().is_empty(), + panel.sessions().next().is_some(), "Debug session should be active" ); }); diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 45cab2a3063a8741d01efb54059667026a646879..9f921ec969debc5247d531469c5132e8485c163b 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -2241,3 +2241,34 @@ func main() { ) .await; } + +#[gpui::test] +async fn test_trim_multi_line_inline_value(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("y", "hello\n world")]; + + let before = r#" +fn main() { + let y = "hello\n world"; +} +"# + .unindent(); + + let after = r#" +fn main() { + let y: hello… = "hello\n world"; +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + rust_lang(), + executor, + cx, + ) + .await; +} diff --git a/crates/debugger_ui/src/tests/module_list.rs b/crates/debugger_ui/src/tests/module_list.rs index 49cfd6fcf88339c7d040d56d575dafce50f8d0f2..09c90cbc4a3af71aa9fb7273cf3535e9f7ece592 100644 --- a/crates/debugger_ui/src/tests/module_list.rs +++ b/crates/debugger_ui/src/tests/module_list.rs @@ -111,7 +111,6 @@ async fn test_module_list(executor: BackgroundExecutor, cx: &mut TestAppContext) }); running_state.update_in(cx, |this, window, cx| { - this.ensure_pane_item(DebuggerPaneItem::Modules, window, cx); this.activate_item(DebuggerPaneItem::Modules, window, cx); cx.refresh_windows(); }); diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index 81c5f7b5983bf351079ec4244e3e7b10170a28ca..0805060bf4413a16d4b7242d133e635bdf4d7cd4 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -1,13 +1,15 @@ use dap::DapRegistry; +use editor::Editor; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; -use project::{FakeFs, Project}; +use project::{FakeFs, Fs as _, Project}; use serde_json::json; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use task::{DebugRequest, DebugScenario, LaunchRequest, TaskContext, VariableName, ZedDebugConfig}; +use text::Point; use util::path; -// use crate::new_process_modal::NewProcessMode; +use crate::NewProcessMode; use crate::tests::{init_test, init_test_workspace}; #[gpui::test] @@ -159,111 +161,127 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths( } } -// #[gpui::test] -// async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(executor.clone()); -// fs.insert_tree( -// path!("/project"), -// json!({ -// "main.rs": "fn main() {}" -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), [path!("/project").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| { -// crate::new_process_modal::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"); - -// modal.update_in(cx, |modal, window, cx| { -// modal.set_configure("/project/main", "/project", false, window, cx); -// modal.save_scenario(window, cx); -// }); - -// cx.executor().run_until_parked(); - -// let debug_json_content = fs -// .load(path!("/project/.zed/debug.json").as_ref()) -// .await -// .expect("debug.json should exist"); - -// let expected_content = vec![ -// "[", -// " {", -// r#" "adapter": "fake-adapter","#, -// r#" "label": "main (fake-adapter)","#, -// r#" "request": "launch","#, -// r#" "program": "/project/main","#, -// r#" "cwd": "/project","#, -// r#" "args": [],"#, -// r#" "env": {}"#, -// " }", -// "]", -// ]; - -// let actual_lines: Vec<&str> = debug_json_content.lines().collect(); -// pretty_assertions::assert_eq!(expected_content, actual_lines); - -// modal.update_in(cx, |modal, window, cx| { -// modal.set_configure("/project/other", "/project", true, window, cx); -// modal.save_scenario(window, cx); -// }); - -// cx.executor().run_until_parked(); - -// let debug_json_content = fs -// .load(path!("/project/.zed/debug.json").as_ref()) -// .await -// .expect("debug.json should exist after second save"); - -// let expected_content = vec![ -// "[", -// " {", -// r#" "adapter": "fake-adapter","#, -// r#" "label": "main (fake-adapter)","#, -// r#" "request": "launch","#, -// r#" "program": "/project/main","#, -// r#" "cwd": "/project","#, -// r#" "args": [],"#, -// r#" "env": {}"#, -// " },", -// " {", -// r#" "adapter": "fake-adapter","#, -// r#" "label": "other (fake-adapter)","#, -// r#" "request": "launch","#, -// r#" "program": "/project/other","#, -// r#" "cwd": "/project","#, -// r#" "args": [],"#, -// r#" "env": {}"#, -// " }", -// "]", -// ]; - -// let actual_lines: Vec<&str> = debug_json_content.lines().collect(); -// pretty_assertions::assert_eq!(expected_content, actual_lines); -// } +#[gpui::test] +async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": "fn main() {}" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").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| { + crate::new_process_modal::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"); + + modal.update_in(cx, |modal, window, cx| { + modal.set_configure("/project/main", "/project", false, window, cx); + modal.save_debug_scenario(window, cx); + }); + + cx.executor().run_until_parked(); + + let editor = workspace + .update(cx, |workspace, _window, cx| { + workspace.active_item_as::(cx).unwrap() + }) + .unwrap(); + + let debug_json_content = fs + .load(path!("/project/.zed/debug.json").as_ref()) + .await + .expect("debug.json should exist") + .lines() + .filter(|line| !line.starts_with("//")) + .collect::>() + .join("\n"); + + let expected_content = indoc::indoc! {r#" + [ + { + "adapter": "fake-adapter", + "label": "main (fake-adapter)", + "request": "launch", + "program": "/project/main", + "cwd": "/project", + "args": [], + "env": {} + } + ]"#}; + + pretty_assertions::assert_eq!(expected_content, debug_json_content); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.newest::(cx).head(), + Point::new(5, 2) + ) + }); + + modal.update_in(cx, |modal, window, cx| { + modal.set_configure("/project/other", "/project", true, window, cx); + modal.save_debug_scenario(window, cx); + }); + + cx.executor().run_until_parked(); + + let expected_content = indoc::indoc! {r#" + [ + { + "adapter": "fake-adapter", + "label": "main (fake-adapter)", + "request": "launch", + "program": "/project/main", + "cwd": "/project", + "args": [], + "env": {} + }, + { + "adapter": "fake-adapter", + "label": "other (fake-adapter)", + "request": "launch", + "program": "/project/other", + "cwd": "/project", + "args": [], + "env": {} + } + ]"#}; + + let debug_json_content = fs + .load(path!("/project/.zed/debug.json").as_ref()) + .await + .expect("debug.json should exist") + .lines() + .filter(|line| !line.starts_with("//")) + .collect::>() + .join("\n"); + pretty_assertions::assert_eq!(expected_content, debug_json_content); +} #[gpui::test] async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) { @@ -272,7 +290,6 @@ async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppConte let mut expected_adapters = vec![ "CodeLLDB", "Debugpy", - "PHP", "JavaScript", "Delve", "GDB", diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 77bb249733f612ede3017e1cff592927b40e8d43..ce7b253702a01e24e7f4a457ac418572e0fa2729 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -144,7 +144,6 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer { style: BlockStyle::Flex, render: Arc::new(move |bcx| block.render_block(editor.clone(), bcx)), priority: 1, - render_in_minimap: false, } }) .collect() diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 8b49c536245a2509cb73254eca8de6d1be1cfd75..b2e0a682056356cddd077d42418a2b4fa763cffa 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -48,7 +48,14 @@ use workspace::{ actions!( diagnostics, - [Deploy, ToggleWarnings, ToggleDiagnosticsRefresh] + [ + /// Opens the project diagnostics view. + Deploy, + /// Toggles the display of warning-level diagnostics. + ToggleWarnings, + /// Toggles automatic refresh of diagnostics. + ToggleDiagnosticsRefresh + ] ); #[derive(Default)] @@ -649,7 +656,6 @@ impl ProjectDiagnosticsEditor { block.render_block(editor.clone(), bcx) }), priority: 1, - render_in_minimap: false, } }); let block_ids = this.editor.update(cx, |editor, cx| { diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 0d47eaf367d6e28708cdf34258fc6080ba500c86..1364aaf853b9b9b0bb44969013a532234da656a3 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -14,7 +14,10 @@ use indoc::indoc; use language::{DiagnosticSourceKind, Rope}; use lsp::LanguageServerId; use pretty_assertions::assert_eq; -use project::FakeFs; +use project::{ + FakeFs, + project_settings::{GoToDiagnosticSeverity, GoToDiagnosticSeverityFilter}, +}; use rand::{Rng, rngs::StdRng, seq::IteratorRandom as _}; use serde_json::json; use settings::SettingsStore; @@ -1005,7 +1008,7 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext) cx.run_until_parked(); cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); assert_eq!( editor .active_diagnostic_group() @@ -1047,7 +1050,7 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext) "}); cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); assert_eq!(editor.active_diagnostic_group(), None); }); cx.assert_editor_state(indoc! {" @@ -1126,7 +1129,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Fourth diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { @@ -1135,7 +1138,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Third diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { @@ -1144,7 +1147,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Second diagnostic, same place cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { @@ -1153,7 +1156,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // First diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { @@ -1162,7 +1165,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Wrapped over, fourth diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { @@ -1181,7 +1184,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // First diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { @@ -1190,7 +1193,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Second diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { @@ -1199,7 +1202,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Third diagnostic, same place cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { @@ -1208,7 +1211,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Fourth diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { @@ -1217,7 +1220,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { // Wrapped around, first diagnostic cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { @@ -1441,6 +1444,128 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) { + init_test(cx); + + let mut cx = EditorTestContext::new(cx).await; + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); + + cx.set_state(indoc! {"error warning info hiˇnt"}); + + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 5), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 13), + ), + severity: Some(lsp::DiagnosticSeverity::WARNING), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 18), + ), + severity: Some(lsp::DiagnosticSeverity::INFORMATION), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 19), + lsp::Position::new(0, 23), + ), + severity: Some(lsp::DiagnosticSeverity::HINT), + ..Default::default() + }, + ], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + cx.run_until_parked(); + + macro_rules! go { + ($severity:expr) => { + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic( + &GoToDiagnostic { + severity: $severity, + }, + window, + cx, + ); + }); + }; + } + + // Default, should cycle through all diagnostics + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning info ˇhint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + + let only_info = GoToDiagnosticSeverityFilter::Only(GoToDiagnosticSeverity::Information); + go!(only_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(only_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + + let no_hints = GoToDiagnosticSeverityFilter::Range { + min: GoToDiagnosticSeverity::Information, + max: GoToDiagnosticSeverity::Error, + }; + + go!(no_hints); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + + let warning_info = GoToDiagnosticSeverityFilter::Range { + min: GoToDiagnosticSeverity::Information, + max: GoToDiagnosticSeverity::Warning, + }; + + go!(warning_info); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(warning_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(warning_info); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); +} + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { zlog::init_test(); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index b5f9e901bbc819414c93ed6300a41a1731699379..4eea5e7e1f7b2fe6d17821615461650266619392 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -6,7 +6,7 @@ use gpui::{ WeakEntity, Window, }; use language::Diagnostic; -use project::project_settings::ProjectSettings; +use project::project_settings::{GoToDiagnosticSeverityFilter, ProjectSettings}; use settings::Settings; use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*}; use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle}; @@ -77,7 +77,7 @@ impl Render for DiagnosticIndicator { .tooltip(|window, cx| { Tooltip::for_action( "Next Diagnostic", - &editor::actions::GoToDiagnostic, + &editor::actions::GoToDiagnostic::default(), window, cx, ) @@ -156,7 +156,12 @@ impl DiagnosticIndicator { fn go_to_next_diagnostic(&mut self, window: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) { editor.update(cx, |editor, cx| { - editor.go_to_diagnostic_impl(editor::Direction::Next, window, cx); + editor.go_to_diagnostic_impl( + editor::Direction::Next, + GoToDiagnosticSeverityFilter::default(), + window, + cx, + ); }) } } diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index c8e945c7e83564d162e0b939b92169b905558393..8eeeb6f0c5a105e186bdeac3e83807e50db721ea 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -243,7 +243,6 @@ struct ActionDef { fn dump_all_gpui_actions() -> Vec { let mut actions = gpui::generate_list_of_all_registered_actions() - .into_iter() .map(|action| ActionDef { name: action.name, human_name: command_palette::humanize_action_name(action.name), diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index bea83b1df826a3dac1cf4afe14a0dd7b417b972b..4d6939567eb8150883a4eb5e4e9e5b0949a421a0 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -109,6 +109,7 @@ theme = { workspace = true, features = ["test-support"] } tree-sitter-html.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true +tree-sitter-yaml.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index b6e784590875c07d5d88382d1306995c40b83939..c4866179c1d98bc1a36001b469cabe875ea42806 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -1,9 +1,11 @@ //! This module contains all actions supported by [`Editor`]. use super::*; use gpui::{Action, actions}; +use project::project_settings::GoToDiagnosticSeverityFilter; use schemars::JsonSchema; use util::serde::default_true; +/// Selects the next occurrence of the current selection. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -12,6 +14,7 @@ pub struct SelectNext { pub replace_newest: bool, } +/// Selects the previous occurrence of the current selection. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -20,6 +23,7 @@ pub struct SelectPrevious { pub replace_newest: bool, } +/// Moves the cursor to the beginning of the current line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -30,6 +34,7 @@ pub struct MoveToBeginningOfLine { pub stop_at_indent: bool, } +/// Selects from the cursor to the beginning of the current line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -40,6 +45,7 @@ pub struct SelectToBeginningOfLine { pub stop_at_indent: bool, } +/// Deletes from the cursor to the beginning of the current line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -48,6 +54,7 @@ pub struct DeleteToBeginningOfLine { pub(super) stop_at_indent: bool, } +/// Moves the cursor up by one page. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -56,6 +63,7 @@ pub struct MovePageUp { pub(super) center_cursor: bool, } +/// Moves the cursor down by one page. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -64,6 +72,7 @@ pub struct MovePageDown { pub(super) center_cursor: bool, } +/// Moves the cursor to the end of the current line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -72,6 +81,7 @@ pub struct MoveToEndOfLine { pub stop_at_soft_wraps: bool, } +/// Selects from the cursor to the end of the current line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -80,6 +90,7 @@ pub struct SelectToEndOfLine { pub(super) stop_at_soft_wraps: bool, } +/// Toggles the display of available code actions at the cursor position. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -101,6 +112,7 @@ pub enum CodeActionSource { QuickActionBar, } +/// Confirms and accepts the currently selected completion suggestion. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -109,6 +121,7 @@ pub struct ConfirmCompletion { pub item_ix: Option, } +/// Composes multiple completion suggestions into a single completion. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -117,6 +130,7 @@ pub struct ComposeCompletion { pub item_ix: Option, } +/// Confirms and applies the currently selected code action. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -125,6 +139,7 @@ pub struct ConfirmCodeAction { pub item_ix: Option, } +/// Toggles comment markers for the selected lines. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -135,6 +150,7 @@ pub struct ToggleComments { pub ignore_indent: bool, } +/// Moves the cursor up by a specified number of lines. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -143,6 +159,7 @@ pub struct MoveUpByLines { pub(super) lines: u32, } +/// Moves the cursor down by a specified number of lines. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -151,6 +168,7 @@ pub struct MoveDownByLines { pub(super) lines: u32, } +/// Extends selection up by a specified number of lines. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -159,6 +177,7 @@ pub struct SelectUpByLines { pub(super) lines: u32, } +/// Extends selection down by a specified number of lines. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -167,6 +186,7 @@ pub struct SelectDownByLines { pub(super) lines: u32, } +/// Expands all excerpts in the editor. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -175,6 +195,7 @@ pub struct ExpandExcerpts { pub(super) lines: u32, } +/// Expands excerpts above the current position. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -183,6 +204,7 @@ pub struct ExpandExcerptsUp { pub(super) lines: u32, } +/// Expands excerpts below the current position. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -191,6 +213,7 @@ pub struct ExpandExcerptsDown { pub(super) lines: u32, } +/// Shows code completion suggestions at the cursor position. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -199,10 +222,12 @@ pub struct ShowCompletions { pub(super) trigger: Option, } +/// Handles text input in the editor. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] pub struct HandleInput(pub String); +/// Deletes from the cursor to the end of the next word. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -211,6 +236,7 @@ pub struct DeleteToNextWordEnd { pub ignore_newlines: bool, } +/// Deletes from the cursor to the start of the previous word. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -219,10 +245,12 @@ pub struct DeleteToPreviousWordStart { pub ignore_newlines: bool, } +/// Folds all code blocks at the specified indentation level. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] pub struct FoldAtLevel(pub u32); +/// Spawns the nearest available task from the current cursor position. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -238,11 +266,38 @@ pub enum UuidVersion { V7, } -actions!(debugger, [RunToCursor, EvaluateSelectedText]); +/// Goes to the next diagnostic in the file. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct GoToDiagnostic { + #[serde(default)] + pub severity: GoToDiagnosticSeverityFilter, +} + +/// Goes to the previous diagnostic in the file. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct GoToPreviousDiagnostic { + #[serde(default)] + pub severity: GoToDiagnosticSeverityFilter, +} + +actions!( + debugger, + [ + /// Runs program execution to the current cursor position. + RunToCursor, + /// Evaluates the selected text in the debugger context. + EvaluateSelectedText + ] +); actions!( go_to_line, [ + /// Toggles the go to line dialog. #[action(name = "Toggle")] ToggleGoToLine ] @@ -251,219 +306,430 @@ actions!( actions!( editor, [ + /// Accepts the full edit prediction. AcceptEditPrediction, + /// Accepts a partial Copilot suggestion. AcceptPartialCopilotSuggestion, + /// Accepts a partial edit prediction. 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. ApplyDiffHunk, + /// Deletes the character before the cursor. Backspace, + /// Cancels the current operation. Cancel, + /// Cancels the running flycheck operation. CancelFlycheck, + /// Cancels pending language server work. CancelLanguageServerWork, + /// Clears flycheck results. ClearFlycheck, + /// Confirms the rename operation. ConfirmRename, + /// Confirms completion by inserting at cursor. ConfirmCompletionInsert, + /// Confirms completion by replacing existing text. ConfirmCompletionReplace, + /// Navigates to the first item in the context menu. ContextMenuFirst, + /// Navigates to the last item in the context menu. ContextMenuLast, + /// Navigates to the next item in the context menu. ContextMenuNext, + /// Navigates to the previous item in the context menu. ContextMenuPrevious, + /// Converts indentation from tabs to spaces. ConvertIndentationToSpaces, + /// Converts indentation from spaces to tabs. ConvertIndentationToTabs, + /// Converts selected text to kebab-case. ConvertToKebabCase, + /// Converts selected text to lowerCamelCase. ConvertToLowerCamelCase, + /// Converts selected text to lowercase. ConvertToLowerCase, + /// Toggles the case of selected text. ConvertToOppositeCase, + /// Converts selected text to snake_case. ConvertToSnakeCase, + /// Converts selected text to Title Case. ConvertToTitleCase, + /// Converts selected text to UpperCamelCase. ConvertToUpperCamelCase, + /// Converts selected text to UPPERCASE. ConvertToUpperCase, + /// Applies ROT13 cipher to selected text. ConvertToRot13, + /// Applies ROT47 cipher to selected text. ConvertToRot47, + /// Copies selected text to the clipboard. Copy, + /// Copies selected text to the clipboard with leading/trailing whitespace trimmed. CopyAndTrim, + /// Copies the current file location to the clipboard. CopyFileLocation, + /// Copies the highlighted text as JSON. CopyHighlightJson, + /// Copies the current file name to the clipboard. CopyFileName, + /// Copies the file name without extension to the clipboard. CopyFileNameWithoutExtension, + /// Copies a permalink to the current line. 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. DeleteLine, + /// Deletes from cursor to end of line. DeleteToEndOfLine, + /// Deletes to the end of the next subword. DeleteToNextSubwordEnd, + /// Deletes to the start of the previous subword. DeleteToPreviousSubwordStart, + /// Displays names of all active cursors. DisplayCursorNames, + /// Duplicates the current line below. DuplicateLineDown, + /// Duplicates the current line above. DuplicateLineUp, + /// Duplicates the current selection. DuplicateSelection, + /// Expands all diff hunks in the editor. #[action(deprecated_aliases = ["editor::ExpandAllHunkDiffs"])] ExpandAllDiffHunks, + /// Expands macros recursively at cursor position. ExpandMacroRecursively, + /// Finds all references to the symbol at cursor. FindAllReferences, + /// Finds the next match in the search. FindNextMatch, + /// Finds the previous match in the search. FindPreviousMatch, + /// Folds the current code block. Fold, + /// Folds all foldable regions in the editor. FoldAll, + /// Folds all function bodies in the editor. FoldFunctionBodies, + /// Folds the current code block and all its children. FoldRecursive, + /// Folds the selected ranges. FoldSelectedRanges, + /// Toggles folding at the current position. ToggleFold, + /// Toggles recursive folding at the current position. ToggleFoldRecursive, + /// Toggles all folds in a buffer or all excerpts in multibuffer. + ToggleFoldAll, + /// Formats the entire document. Format, + /// Formats only the selected text. FormatSelections, + /// Goes to the declaration of the symbol at cursor. GoToDeclaration, + /// Goes to declaration in a split pane. GoToDeclarationSplit, + /// Goes to the definition of the symbol at cursor. GoToDefinition, + /// Goes to definition in a split pane. GoToDefinitionSplit, - GoToDiagnostic, + /// Goes to the next diff hunk. GoToHunk, + /// Goes to the previous diff hunk. GoToPreviousHunk, + /// Goes to the implementation of the symbol at cursor. GoToImplementation, + /// Goes to implementation in a split pane. GoToImplementationSplit, + /// Goes to the next change in the file. GoToNextChange, + /// Goes to the parent module of the current file. GoToParentModule, + /// Goes to the previous change in the file. GoToPreviousChange, - GoToPreviousDiagnostic, + /// Goes to the type definition of the symbol at cursor. GoToTypeDefinition, + /// Goes to type definition in a split pane. GoToTypeDefinitionSplit, + /// Scrolls down by half a page. HalfPageDown, + /// Scrolls up by half a page. HalfPageUp, + /// Shows hover information for the symbol at cursor. Hover, + /// Increases indentation of selected lines. Indent, + /// Inserts a UUID v4 at cursor position. InsertUuidV4, + /// Inserts a UUID v7 at cursor position. InsertUuidV7, + /// Joins the current line with the next line. JoinLines, + /// Cuts to kill ring (Emacs-style). KillRingCut, + /// Yanks from kill ring (Emacs-style). KillRingYank, + /// Moves cursor down one line. LineDown, + /// Moves cursor up one line. LineUp, + /// Moves cursor down. MoveDown, + /// Moves cursor left. MoveLeft, + /// Moves the current line down. MoveLineDown, + /// Moves the current line up. MoveLineUp, + /// Moves cursor right. MoveRight, + /// Moves cursor to the beginning of the document. MoveToBeginning, + /// Moves cursor to the enclosing bracket. MoveToEnclosingBracket, + /// Moves cursor to the end of the document. MoveToEnd, + /// Moves cursor to the end of the paragraph. MoveToEndOfParagraph, + /// Moves cursor to the end of the next subword. MoveToNextSubwordEnd, + /// Moves cursor to the end of the next word. MoveToNextWordEnd, + /// Moves cursor to the start of the previous subword. MoveToPreviousSubwordStart, + /// Moves cursor to the start of the previous word. MoveToPreviousWordStart, + /// Moves cursor to the start of the paragraph. MoveToStartOfParagraph, + /// Moves cursor to the start of the current excerpt. MoveToStartOfExcerpt, + /// Moves cursor to the start of the next excerpt. MoveToStartOfNextExcerpt, + /// Moves cursor to the end of the current excerpt. MoveToEndOfExcerpt, + /// Moves cursor to the end of the previous excerpt. MoveToEndOfPreviousExcerpt, + /// Moves cursor up. MoveUp, + /// Inserts a new line and moves cursor to it. Newline, + /// Inserts a new line above the current line. NewlineAbove, + /// Inserts a new line below the current line. NewlineBelow, + /// Navigates to the next edit prediction. NextEditPrediction, + /// Scrolls to the next screen. NextScreen, + /// Opens the context menu at cursor position. OpenContextMenu, + /// Opens excerpts from the current file. OpenExcerpts, + /// Opens excerpts in a split pane. OpenExcerptsSplit, + /// Opens the proposed changes editor. OpenProposedChangesEditor, + /// Opens documentation for the symbol at cursor. OpenDocs, + /// Opens a permalink to the current line. OpenPermalinkToLine, + /// Opens the file whose name is selected in the editor. #[action(deprecated_aliases = ["editor::OpenFile"])] OpenSelectedFilename, + /// Opens all selections in a multibuffer. OpenSelectionsInMultibuffer, + /// Opens the URL at cursor position. OpenUrl, + /// Organizes import statements. OrganizeImports, + /// Decreases indentation of selected lines. Outdent, + /// Automatically adjusts indentation based on context. AutoIndent, + /// Scrolls down by one page. PageDown, + /// Scrolls up by one page. PageUp, + /// Pastes from clipboard. Paste, + /// Navigates to the previous edit prediction. PreviousEditPrediction, + /// Redoes the last undone edit. Redo, + /// Redoes the last selection change. RedoSelection, + /// Renames the symbol at cursor. Rename, + /// Restarts the language server for the current file. RestartLanguageServer, + /// Reveals the current file in the system file manager. RevealInFileManager, + /// Reverses the order of selected lines. ReverseLines, + /// Reloads the file from disk. ReloadFile, + /// Rewraps text to fit within the preferred line length. Rewrap, + /// Runs flycheck diagnostics. RunFlycheck, + /// Scrolls the cursor to the bottom of the viewport. ScrollCursorBottom, + /// Scrolls the cursor to the center of the viewport. ScrollCursorCenter, + /// Cycles cursor position between center, top, and bottom. ScrollCursorCenterTopBottom, + /// Scrolls the cursor to the top of the viewport. ScrollCursorTop, + /// Selects all text in the editor. SelectAll, + /// Selects all matches of the current selection. SelectAllMatches, + /// Selects to the start of the current excerpt. SelectToStartOfExcerpt, + /// Selects to the start of the next excerpt. SelectToStartOfNextExcerpt, + /// Selects to the end of the current excerpt. SelectToEndOfExcerpt, + /// Selects to the end of the previous excerpt. SelectToEndOfPreviousExcerpt, + /// Extends selection down. SelectDown, + /// Selects the enclosing symbol. SelectEnclosingSymbol, + /// Selects the next larger syntax node. SelectLargerSyntaxNode, + /// Extends selection left. SelectLeft, + /// Selects the current line. SelectLine, + /// Extends selection down by one page. SelectPageDown, + /// Extends selection up by one page. SelectPageUp, + /// Extends selection right. SelectRight, + /// Selects the next smaller syntax node. SelectSmallerSyntaxNode, + /// Selects to the beginning of the document. SelectToBeginning, + /// Selects to the end of the document. SelectToEnd, + /// Selects to the end of the paragraph. SelectToEndOfParagraph, + /// Selects to the end of the next subword. SelectToNextSubwordEnd, + /// Selects to the end of the next word. SelectToNextWordEnd, + /// Selects to the start of the previous subword. SelectToPreviousSubwordStart, + /// Selects to the start of the previous word. SelectToPreviousWordStart, + /// Selects to the start of the paragraph. SelectToStartOfParagraph, + /// Extends selection up. SelectUp, + /// Shows the system character palette. ShowCharacterPalette, + /// Shows edit prediction at cursor. ShowEditPrediction, + /// Shows signature help for the current function. ShowSignatureHelp, + /// Shows word completions. ShowWordCompletions, + /// Randomly shuffles selected lines. ShuffleLines, + /// Navigates to the next signature in the signature help popup. SignatureHelpNext, + /// Navigates to the previous signature in the signature help popup. SignatureHelpPrevious, + /// Sorts selected lines by length. + SortLinesByLength, + /// Sorts selected lines case-insensitively. SortLinesCaseInsensitive, + /// Sorts selected lines case-sensitively. SortLinesCaseSensitive, + /// Splits selection into individual lines. SplitSelectionIntoLines, + /// Stops the language server for the current file. StopLanguageServer, + /// Switches between source and header files. SwitchSourceHeader, + /// Inserts a tab character or indents. Tab, + /// Removes a tab character or outdents. Backtab, + /// Toggles a breakpoint at the current line. ToggleBreakpoint, + /// Toggles the case of selected text. ToggleCase, + /// Disables the breakpoint at the current line. DisableBreakpoint, + /// Enables the breakpoint at the current line. EnableBreakpoint, + /// Edits the log message for a breakpoint. EditLogBreakpoint, + /// Toggles automatic signature help. ToggleAutoSignatureHelp, + /// Toggles inline git blame display. ToggleGitBlameInline, + /// Opens the git commit for the blame at cursor. OpenGitBlameCommit, + /// Toggles the diagnostics panel. ToggleDiagnostics, + /// Toggles indent guides display. ToggleIndentGuides, + /// Toggles inlay hints display. ToggleInlayHints, + /// Toggles inline values display. ToggleInlineValues, + /// Toggles inline diagnostics display. ToggleInlineDiagnostics, + /// Toggles edit prediction feature. ToggleEditPrediction, + /// Toggles line numbers display. ToggleLineNumbers, + /// Toggles the minimap display. ToggleMinimap, + /// Swaps the start and end of the current selection. SwapSelectionEnds, + /// Sets a mark at the current position. SetMark, + /// Toggles relative line numbers display. ToggleRelativeLineNumbers, + /// Toggles diff display for selected hunks. #[action(deprecated_aliases = ["editor::ToggleHunkDiff"])] ToggleSelectedDiffHunks, + /// Toggles the selection menu. ToggleSelectionMenu, + /// Toggles soft wrap mode. ToggleSoftWrap, + /// Toggles the tab bar display. ToggleTabBar, + /// Transposes characters around cursor. Transpose, + /// Undoes the last edit. Undo, + /// Undoes the last selection change. UndoSelection, + /// Unfolds all folded regions. UnfoldAll, + /// Unfolds lines at cursor. UnfoldLines, + /// Unfolds recursively at cursor. UnfoldRecursive, + /// Removes duplicate lines (case-insensitive). UniqueLinesCaseInsensitive, + /// Removes duplicate lines (case-sensitive). UniqueLinesCaseSensitive, ] ); diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 291c03422def426054457c04ab8c9e4e710112a7..8fbae8d6052d89299b10f3cd0c971af79abd3c90 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1083,11 +1083,10 @@ impl CompletionsMenu { if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) ); - let sort_text = if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source - { - lsp_completion.sort_text.as_deref() - } else { - None + let sort_text = match &completion.source { + CompletionSource::Lsp { lsp_completion, .. } => lsp_completion.sort_text.as_deref(), + CompletionSource::Dap { sort_text } => Some(sort_text.as_str()), + _ => None, }; let (sort_kind, sort_label) = completion.sort_key(); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3352d21ef878835987e0227a926dfb61c893a182..5425d5a8b970a9e4febbc55a4a64e31d1f373d55 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -271,7 +271,6 @@ impl DisplayMap { height: Some(height), style, priority, - render_in_minimap: true, } }), ); @@ -1066,7 +1065,7 @@ impl DisplaySnapshot { } let font_size = editor_style.text.font_size.to_pixels(*rem_size); - text_system.layout_line(&line, font_size, &runs) + text_system.layout_line(&line, font_size, &runs, None) } pub fn x_for_display_point( @@ -1663,7 +1662,6 @@ pub mod tests { height: Some(height), render: Arc::new(|_| div().into_any()), priority, - render_in_minimap: true, } }) .collect::>(); @@ -2029,7 +2027,6 @@ pub mod tests { style: BlockStyle::Sticky, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }], cx, ); @@ -2227,7 +2224,6 @@ pub mod tests { style: BlockStyle::Sticky, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { placement: BlockPlacement::Below( @@ -2237,7 +2233,6 @@ pub mod tests { style: BlockStyle::Sticky, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ], cx, @@ -2344,7 +2339,6 @@ pub mod tests { style: BlockStyle::Sticky, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }], cx, ) @@ -2420,7 +2414,6 @@ pub mod tests { style: BlockStyle::Fixed, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }], cx, ); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index ea754da03f70ff87e28bb73a614fad6b66d7e4c2..c761e0d69ceea5a8e36441df410071016dc1f200 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -193,7 +193,6 @@ pub struct CustomBlock { style: BlockStyle, render: Arc>, priority: usize, - pub(crate) render_in_minimap: bool, } #[derive(Clone)] @@ -205,7 +204,6 @@ pub struct BlockProperties

{ pub style: BlockStyle, pub render: RenderBlock, pub priority: usize, - pub render_in_minimap: bool, } impl Debug for BlockProperties

{ @@ -1044,7 +1042,6 @@ impl BlockMapWriter<'_> { render: Arc::new(Mutex::new(block.render)), style: block.style, priority: block.priority, - render_in_minimap: block.render_in_minimap, }); self.0.custom_blocks.insert(block_ix, new_block.clone()); self.0.custom_blocks_by_id.insert(id, new_block); @@ -1079,7 +1076,6 @@ impl BlockMapWriter<'_> { style: block.style, render: block.render.clone(), priority: block.priority, - render_in_minimap: block.render_in_minimap, }; let new_block = Arc::new(new_block); *block = new_block.clone(); @@ -1976,7 +1972,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -1984,7 +1979,6 @@ mod tests { height: Some(2), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -1992,7 +1986,6 @@ mod tests { height: Some(3), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); @@ -2217,7 +2210,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2225,7 +2217,6 @@ mod tests { height: Some(2), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2233,7 +2224,6 @@ mod tests { height: Some(3), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); @@ -2322,7 +2312,6 @@ mod tests { render: Arc::new(|_| div().into_any()), height: Some(1), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2330,7 +2319,6 @@ mod tests { render: Arc::new(|_| div().into_any()), height: Some(1), priority: 0, - render_in_minimap: true, }, ]); @@ -2370,7 +2358,6 @@ mod tests { height: Some(4), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }])[0]; let blocks_snapshot = block_map.read(wraps_snapshot, Default::default()); @@ -2424,7 +2411,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2432,7 +2418,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2440,7 +2425,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); @@ -2455,7 +2439,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2463,7 +2446,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2471,7 +2453,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); @@ -2571,7 +2552,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2579,7 +2559,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2587,7 +2566,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); let excerpt_blocks_3 = writer.insert(vec![ @@ -2597,7 +2575,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, BlockProperties { style: BlockStyle::Fixed, @@ -2605,7 +2582,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }, ]); @@ -2653,7 +2629,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }]); let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); let blocks = blocks_snapshot @@ -3011,7 +2986,6 @@ mod tests { height: Some(height), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, } }) .collect::>(); @@ -3032,7 +3006,6 @@ mod tests { style: props.style, render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, })); for (block_properties, block_id) in block_properties.iter().zip(block_ids) { @@ -3557,7 +3530,6 @@ mod tests { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }])[0]; let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 49b5ce1d26916de0ec79ab80ec21f1bcf8b335e3..f7a696860a1c85d6955fe9e6f5aa00c0fa32a156 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -296,12 +296,25 @@ impl<'a> Iterator for InlayChunks<'a> { *chunk = self.buffer_chunks.next().unwrap(); } - let (prefix, suffix) = chunk.text.split_at( - chunk - .text - .len() - .min(self.transforms.end(&()).0.0 - self.output_offset.0), - ); + let desired_bytes = self.transforms.end(&()).0.0 - self.output_offset.0; + + // If we're already at the transform boundary, skip to the next transform + if desired_bytes == 0 { + self.inlay_chunks = None; + self.transforms.next(&()); + return self.next(); + } + + // Determine split index handling edge cases + let split_index = if desired_bytes >= chunk.text.len() { + chunk.text.len() + } else if chunk.text.is_char_boundary(desired_bytes) { + desired_bytes + } else { + find_next_utf8_boundary(chunk.text, desired_bytes) + }; + + let (prefix, suffix) = chunk.text.split_at(split_index); chunk.text = suffix; self.output_offset.0 += prefix.len(); @@ -391,8 +404,24 @@ impl<'a> Iterator for InlayChunks<'a> { let inlay_chunk = self .inlay_chunk .get_or_insert_with(|| inlay_chunks.next().unwrap()); - let (chunk, remainder) = - inlay_chunk.split_at(inlay_chunk.len().min(next_inlay_highlight_endpoint)); + + // Determine split index handling edge cases + let split_index = if next_inlay_highlight_endpoint >= inlay_chunk.len() { + inlay_chunk.len() + } else if next_inlay_highlight_endpoint == 0 { + // Need to take at least one character to make progress + inlay_chunk + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(1) + } else if inlay_chunk.is_char_boundary(next_inlay_highlight_endpoint) { + next_inlay_highlight_endpoint + } else { + find_next_utf8_boundary(inlay_chunk, next_inlay_highlight_endpoint) + }; + + let (chunk, remainder) = inlay_chunk.split_at(split_index); *inlay_chunk = remainder; if inlay_chunk.is_empty() { self.inlay_chunk = None; @@ -412,7 +441,7 @@ impl<'a> Iterator for InlayChunks<'a> { } }; - if self.output_offset == self.transforms.end(&()).0 { + if self.output_offset >= self.transforms.end(&()).0 { self.inlay_chunks = None; self.transforms.next(&()); } @@ -1143,6 +1172,31 @@ fn push_isomorphic(sum_tree: &mut SumTree, summary: TextSummary) { } } +/// Given a byte index that is NOT a UTF-8 boundary, find the next one. +/// Assumes: 0 < byte_index < text.len() and !text.is_char_boundary(byte_index) +#[inline(always)] +fn find_next_utf8_boundary(text: &str, byte_index: usize) -> usize { + let bytes = text.as_bytes(); + let mut idx = byte_index + 1; + + // Scan forward until we find a boundary + while idx < text.len() { + if is_utf8_char_boundary(bytes[idx]) { + return idx; + } + idx += 1; + } + + // Hit the end, return the full length + text.len() +} + +// Private helper function taken from Rust's core::num module (which is both Apache2 and MIT licensed) +const fn is_utf8_char_boundary(byte: u8) -> bool { + // This is bit magic equivalent to: b < 128 || b >= 192 + (byte as i8) >= -0x40 +} + #[cfg(test)] mod tests { use super::*; @@ -1882,4 +1936,210 @@ mod tests { cx.set_global(store); theme::init(theme::LoadThemes::JustBase, cx); } + + /// Helper to create test highlights for an inlay + fn create_inlay_highlights( + inlay_id: InlayId, + highlight_range: Range, + position: Anchor, + ) -> TreeMap> { + let mut inlay_highlights = TreeMap::default(); + let mut type_highlights = TreeMap::default(); + type_highlights.insert( + inlay_id, + ( + HighlightStyle::default(), + InlayHighlight { + inlay: inlay_id, + range: highlight_range, + inlay_position: position, + }, + ), + ); + inlay_highlights.insert(TypeId::of::<()>(), type_highlights); + inlay_highlights + } + + #[gpui::test] + fn test_inlay_utf8_boundary_panic_fix(cx: &mut App) { + init_test(cx); + + // This test verifies that we handle UTF-8 character boundaries correctly + // when splitting inlay text for highlighting. Previously, this would panic + // when trying to split at byte 13, which is in the middle of the '…' character. + // + // See https://github.com/zed-industries/zed/issues/33641 + let buffer = MultiBuffer::build_simple("fn main() {}\n", cx); + let (mut inlay_map, _) = InlayMap::new(buffer.read(cx).snapshot(cx)); + + // Create an inlay with text that contains a multi-byte character + // The string "SortingDirec…" contains an ellipsis character '…' which is 3 bytes (E2 80 A6) + let inlay_text = "SortingDirec…"; + let position = buffer.read(cx).snapshot(cx).anchor_before(Point::new(0, 5)); + + let inlay = Inlay { + id: InlayId::Hint(0), + position, + text: text::Rope::from(inlay_text), + color: None, + }; + + let (inlay_snapshot, _) = inlay_map.splice(&[], vec![inlay]); + + // Create highlights that request a split at byte 13, which is in the middle + // of the '…' character (bytes 12..15). We include the full character. + let inlay_highlights = create_inlay_highlights(InlayId::Hint(0), 0..13, position); + + let highlights = crate::display_map::Highlights { + text_highlights: None, + inlay_highlights: Some(&inlay_highlights), + styles: crate::display_map::HighlightStyles::default(), + }; + + // Collect chunks - this previously would panic + let chunks: Vec<_> = inlay_snapshot + .chunks( + InlayOffset(0)..InlayOffset(inlay_snapshot.len().0), + false, + highlights, + ) + .collect(); + + // Verify the chunks are correct + let full_text: String = chunks.iter().map(|c| c.chunk.text).collect(); + assert_eq!(full_text, "fn maSortingDirec…in() {}\n"); + + // Verify the highlighted portion includes the complete ellipsis character + let highlighted_chunks: Vec<_> = chunks + .iter() + .filter(|c| c.chunk.highlight_style.is_some() && c.chunk.is_inlay) + .collect(); + + assert_eq!(highlighted_chunks.len(), 1); + assert_eq!(highlighted_chunks[0].chunk.text, "SortingDirec…"); + } + + #[gpui::test] + fn test_inlay_utf8_boundaries(cx: &mut App) { + init_test(cx); + + struct TestCase { + inlay_text: &'static str, + highlight_range: Range, + expected_highlighted: &'static str, + description: &'static str, + } + + let test_cases = vec![ + TestCase { + inlay_text: "Hello👋World", + highlight_range: 0..7, + expected_highlighted: "Hello👋", + description: "Emoji boundary - rounds up to include full emoji", + }, + TestCase { + inlay_text: "Test→End", + highlight_range: 0..5, + expected_highlighted: "Test→", + description: "Arrow boundary - rounds up to include full arrow", + }, + TestCase { + inlay_text: "café", + highlight_range: 0..4, + expected_highlighted: "café", + description: "Accented char boundary - rounds up to include full é", + }, + TestCase { + inlay_text: "🎨🎭🎪", + highlight_range: 0..5, + expected_highlighted: "🎨🎭", + description: "Multiple emojis - partial highlight", + }, + TestCase { + inlay_text: "普通话", + highlight_range: 0..4, + expected_highlighted: "普通", + description: "Chinese characters - partial highlight", + }, + TestCase { + inlay_text: "Hello", + highlight_range: 0..2, + expected_highlighted: "He", + description: "ASCII only - no adjustment needed", + }, + TestCase { + inlay_text: "👋", + highlight_range: 0..1, + expected_highlighted: "👋", + description: "Single emoji - partial byte range includes whole char", + }, + TestCase { + inlay_text: "Test", + highlight_range: 0..0, + expected_highlighted: "", + description: "Empty range", + }, + TestCase { + inlay_text: "🎨ABC", + highlight_range: 2..5, + expected_highlighted: "A", + description: "Range starting mid-emoji skips the emoji", + }, + ]; + + for test_case in test_cases { + let buffer = MultiBuffer::build_simple("test", cx); + let (mut inlay_map, _) = InlayMap::new(buffer.read(cx).snapshot(cx)); + let position = buffer.read(cx).snapshot(cx).anchor_before(Point::new(0, 2)); + + let inlay = Inlay { + id: InlayId::Hint(0), + position, + text: text::Rope::from(test_case.inlay_text), + color: None, + }; + + let (inlay_snapshot, _) = inlay_map.splice(&[], vec![inlay]); + let inlay_highlights = create_inlay_highlights( + InlayId::Hint(0), + test_case.highlight_range.clone(), + position, + ); + + let highlights = crate::display_map::Highlights { + text_highlights: None, + inlay_highlights: Some(&inlay_highlights), + styles: crate::display_map::HighlightStyles::default(), + }; + + let chunks: Vec<_> = inlay_snapshot + .chunks( + InlayOffset(0)..InlayOffset(inlay_snapshot.len().0), + false, + highlights, + ) + .collect(); + + // Verify we got chunks and they total to the expected text + let full_text: String = chunks.iter().map(|c| c.chunk.text).collect(); + assert_eq!( + full_text, + format!("te{}st", test_case.inlay_text), + "Full text mismatch for case: {}", + test_case.description + ); + + // Verify that the highlighted portion matches expectations + let highlighted_text: String = chunks + .iter() + .filter(|c| c.chunk.highlight_style.is_some() && c.chunk.is_inlay) + .map(|c| c.chunk.text) + .collect(); + assert_eq!( + highlighted_text, test_case.expected_highlighted, + "Highlighted text mismatch for case: {} (text: '{}', range: {:?})", + test_case.description, test_case.inlay_text, test_case.highlight_range + ); + } + } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 69b9158c31a7279b2222dd150e2fbbd4f8268224..72470c0a7d13cc75f83102d2c61c23c478063053 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -134,7 +134,7 @@ use project::{ session::{Session, SessionEvent}, }, git_store::{GitStoreEvent, RepositoryEvent}, - project_settings::DiagnosticSeverity, + project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter}, }; pub use git::blame::BlameRenderer; @@ -865,9 +865,19 @@ pub trait Addon: 'static { } } +struct ChangeLocation { + current: Option>, + original: Vec, +} +impl ChangeLocation { + fn locations(&self) -> &[Anchor] { + self.current.as_ref().unwrap_or(&self.original) + } +} + /// A set of caret positions, registered when the editor was edited. pub struct ChangeList { - changes: Vec>, + changes: Vec, /// Currently "selected" change. position: Option, } @@ -894,20 +904,38 @@ impl ChangeList { (prev + count).min(self.changes.len() - 1) }; self.position = Some(next); - self.changes.get(next).map(|anchors| anchors.as_slice()) + self.changes.get(next).map(|change| change.locations()) } /// Adds a new change to the list, resetting the change list position. - pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec) { + pub fn push_to_change_list(&mut self, group: bool, new_positions: Vec) { self.position.take(); - if pop_state { - self.changes.pop(); + if let Some(last) = self.changes.last_mut() + && group + { + last.current = Some(new_positions) + } else { + self.changes.push(ChangeLocation { + original: new_positions, + current: None, + }); } - self.changes.push(new_positions.clone()); } pub fn last(&self) -> Option<&[Anchor]> { - self.changes.last().map(|anchors| anchors.as_slice()) + self.changes.last().map(|change| change.locations()) + } + + pub fn last_before_grouping(&self) -> Option<&[Anchor]> { + self.changes.last().map(|change| change.original.as_slice()) + } + + 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); + } + } } } @@ -1142,7 +1170,6 @@ pub struct Editor { pub change_list: ChangeList, inline_value_cache: InlineValueCache, selection_drag_state: SelectionDragState, - drag_and_drop_selection_enabled: bool, next_color_inlay_id: usize, colors: Option, folding_newlines: Task<()>, @@ -1768,6 +1795,7 @@ impl Editor { ); let full_mode = mode.is_full(); + let is_minimap = mode.is_minimap(); let diagnostics_max_severity = if full_mode { EditorSettings::get_global(cx) .diagnostics_max_severity @@ -1828,13 +1856,19 @@ impl Editor { let selections = SelectionsCollection::new(display_map.clone(), buffer.clone()); - let blink_manager = cx.new(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx)); + let blink_manager = cx.new(|cx| { + let mut blink_manager = BlinkManager::new(CURSOR_BLINK_INTERVAL, cx); + if is_minimap { + blink_manager.disable(cx); + } + blink_manager + }); let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. }) .then(|| language_settings::SoftWrap::None); let mut project_subscriptions = Vec::new(); - if mode.is_full() { + if full_mode { if let Some(project) = project.as_ref() { project_subscriptions.push(cx.subscribe_in( project, @@ -1945,18 +1979,23 @@ impl Editor { let inlay_hint_settings = inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); let focus_handle = cx.focus_handle(); - cx.on_focus(&focus_handle, window, Self::handle_focus) - .detach(); - cx.on_focus_in(&focus_handle, window, Self::handle_focus_in) - .detach(); - cx.on_focus_out(&focus_handle, window, Self::handle_focus_out) - .detach(); - cx.on_blur(&focus_handle, window, Self::handle_blur) - .detach(); - cx.observe_pending_input(window, Self::observe_pending_input) - .detach(); - - let show_indent_guides = if matches!(mode, EditorMode::SingleLine { .. }) { + if !is_minimap { + cx.on_focus(&focus_handle, window, Self::handle_focus) + .detach(); + cx.on_focus_in(&focus_handle, window, Self::handle_focus_in) + .detach(); + cx.on_focus_out(&focus_handle, window, Self::handle_focus_out) + .detach(); + cx.on_blur(&focus_handle, window, Self::handle_blur) + .detach(); + cx.observe_pending_input(window, Self::observe_pending_input) + .detach(); + } + + let show_indent_guides = if matches!( + mode, + EditorMode::SingleLine { .. } | EditorMode::Minimap { .. } + ) { Some(false) } else { None @@ -2022,10 +2061,10 @@ impl Editor { minimap_visibility: MinimapVisibility::for_mode(&mode, cx), offset_content: !matches!(mode, EditorMode::SingleLine { .. }), show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, - show_gutter: mode.is_full(), - show_line_numbers: None, + show_gutter: full_mode, + show_line_numbers: (!full_mode).then_some(false), use_relative_line_numbers: None, - disable_expand_excerpt_buttons: false, + disable_expand_excerpt_buttons: !full_mode, show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, @@ -2059,7 +2098,7 @@ impl Editor { document_highlights_task: None, linked_editing_range_task: None, pending_rename: None, - searchable: true, + searchable: !is_minimap, cursor_shape: EditorSettings::get_global(cx) .cursor_shape .unwrap_or_default(), @@ -2067,9 +2106,9 @@ impl Editor { autoindent_mode: Some(AutoindentMode::EachLine), collapse_matches: false, workspace: None, - input_enabled: true, - use_modal_editing: mode.is_full(), - read_only: mode.is_minimap(), + input_enabled: !is_minimap, + use_modal_editing: full_mode, + read_only: is_minimap, use_autoclose: true, use_auto_surround: true, auto_replace_emoji_shortcode: false, @@ -2085,11 +2124,10 @@ impl Editor { edit_prediction_preview: EditPredictionPreview::Inactive { released_too_fast: false, }, - inline_diagnostics_enabled: mode.is_full(), - diagnostics_enabled: mode.is_full(), + inline_diagnostics_enabled: full_mode, + diagnostics_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, @@ -2112,9 +2150,10 @@ impl Editor { show_git_blame_inline: false, show_selection_menu: None, show_git_blame_inline_delay_task: None, - git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(), + git_blame_inline_enabled: full_mode + && ProjectSettings::get_global(cx).git.inline_blame_enabled(), render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), - serialize_dirty_buffers: !mode.is_minimap() + serialize_dirty_buffers: !is_minimap && ProjectSettings::get_global(cx) .session .restore_unsaved_buffers, @@ -2125,27 +2164,31 @@ impl Editor { breakpoint_store, gutter_breakpoint_indicator: (None, None), hovered_diff_hunk_row: None, - _subscriptions: vec![ - cx.observe(&buffer, Self::on_buffer_changed), - cx.subscribe_in(&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), - observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), - cx.observe_window_activation(window, |editor, window, cx| { - let active = window.is_window_active(); - editor.blink_manager.update(cx, |blink_manager, cx| { - if active { - blink_manager.enable(cx); - } else { - blink_manager.disable(cx); - } - }); - if active { - editor.show_mouse_cursor(cx); - } - }), - ], + _subscriptions: (!is_minimap) + .then(|| { + vec![ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&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), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { + if active { + blink_manager.enable(cx); + } else { + blink_manager.disable(cx); + } + }); + if active { + editor.show_mouse_cursor(cx); + } + }), + ] + }) + .unwrap_or_default(), tasks_update_task: None, pull_diagnostics_task: Task::ready(()), colors: None, @@ -2174,9 +2217,13 @@ impl Editor { change_list: ChangeList::new(), mode, selection_drag_state: SelectionDragState::None, - drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection, folding_newlines: Task::ready(()), }; + + if is_minimap { + return editor; + } + if let Some(breakpoints) = editor.breakpoint_store.as_ref() { editor ._subscriptions @@ -2296,7 +2343,10 @@ impl Editor { editor.update_lsp_data(false, None, window, cx); } - editor.report_editor_event("Editor Opened", None, cx); + if editor.mode.is_full() { + editor.report_editor_event("Editor Opened", None, cx); + } + editor } @@ -4355,7 +4405,7 @@ impl Editor { .take_while(|c| c.is_whitespace()) .count(); let comment_candidate = snapshot - .chars_for_range(range) + .chars_for_range(range.clone()) .skip(num_of_whitespaces) .take(max_len_of_delimiter) .collect::(); @@ -4371,6 +4421,22 @@ impl Editor { }) .max_by_key(|(_, len)| *len)?; + if let Some((block_start, _)) = language.block_comment_delimiters() + { + let block_start_trimmed = block_start.trim_end(); + if block_start_trimmed.starts_with(delimiter.trim_end()) { + let line_content = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(block_start_trimmed.len()) + .collect::(); + + if line_content.starts_with(block_start_trimmed) { + return None; + } + } + } + let cursor_is_placed_after_comment_marker = num_of_whitespaces + trimmed_len <= start_point.column as usize; if cursor_is_placed_after_comment_marker { @@ -8715,7 +8781,7 @@ impl Editor { h_flex() .bg(cx.theme().colors().editor_background) .border(BORDER_WIDTH) - .shadow_sm() + .shadow_xs() .border_color(cx.theme().colors().border) .rounded_l_lg() .when(line_count > 1, |el| el.rounded_br_lg()) @@ -8915,7 +8981,7 @@ impl Editor { .border_1() .bg(Self::edit_prediction_line_popover_bg_color(cx)) .border_color(Self::edit_prediction_callout_popover_border_color(cx)) - .shadow_sm() + .shadow_xs() .when(!has_keybind, |el| { let status_colors = cx.theme().status(); @@ -10204,6 +10270,17 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) } + pub fn sort_lines_by_length( + &mut self, + _: &SortLinesByLength, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_immutable_lines(window, cx, |lines| { + lines.sort_by_key(|&line| line.chars().count()) + }) + } + pub fn sort_lines_case_insensitive( &mut self, _: &SortLinesCaseInsensitive, @@ -10389,7 +10466,6 @@ impl Editor { cloned_prompt.clone().into_any_element() }), priority: 0, - render_in_minimap: true, }]; let focus_handle = bp_prompt.focus_handle(cx); @@ -15010,7 +15086,7 @@ impl Editor { pub fn go_to_diagnostic( &mut self, - _: &GoToDiagnostic, + action: &GoToDiagnostic, window: &mut Window, cx: &mut Context, ) { @@ -15018,12 +15094,12 @@ impl Editor { return; } self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); - self.go_to_diagnostic_impl(Direction::Next, window, cx) + self.go_to_diagnostic_impl(Direction::Next, action.severity, window, cx) } pub fn go_to_prev_diagnostic( &mut self, - _: &GoToPreviousDiagnostic, + action: &GoToPreviousDiagnostic, window: &mut Window, cx: &mut Context, ) { @@ -15031,12 +15107,13 @@ impl Editor { return; } self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); - self.go_to_diagnostic_impl(Direction::Prev, window, cx) + self.go_to_diagnostic_impl(Direction::Prev, action.severity, window, cx) } pub fn go_to_diagnostic_impl( &mut self, direction: Direction, + severity: GoToDiagnosticSeverityFilter, window: &mut Window, cx: &mut Context, ) { @@ -15052,9 +15129,11 @@ impl Editor { fn filtered( snapshot: EditorSnapshot, + severity: GoToDiagnosticSeverityFilter, diagnostics: impl Iterator>, ) -> impl Iterator> { diagnostics + .filter(move |entry| severity.matches(entry.diagnostic.severity)) .filter(|entry| entry.range.start != entry.range.end) .filter(|entry| !entry.diagnostic.is_unnecessary) .filter(move |entry| !snapshot.intersects_fold(entry.range.start)) @@ -15063,12 +15142,14 @@ impl Editor { let snapshot = self.snapshot(window, cx); let before = filtered( snapshot.clone(), + severity, buffer .diagnostics_in_range(0..selection.start) .filter(|entry| entry.range.start <= selection.start), ); let after = filtered( snapshot, + severity, buffer .diagnostics_in_range(selection.start..buffer.len()) .filter(|entry| entry.range.start >= selection.start), @@ -16085,7 +16166,6 @@ impl Editor { } }), priority: 0, - render_in_minimap: true, }], Some(Autoscroll::fit()), cx, @@ -17019,6 +17099,46 @@ impl Editor { } } + pub fn toggle_fold_all( + &mut self, + _: &actions::ToggleFoldAll, + window: &mut Window, + cx: &mut Context, + ) { + 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()) + .next() + .is_some(); + + if has_folds { + self.unfold_all(&actions::UnfoldAll, window, cx); + } else { + self.fold_all(&actions::FoldAll, window, cx); + } + } else { + let buffer_ids = self.buffer.read(cx).excerpt_buffer_ids(); + let should_unfold = buffer_ids + .iter() + .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); + + self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { + editor + .update_in(cx, |editor, _, cx| { + for buffer_id in buffer_ids { + if should_unfold { + editor.unfold_buffer(buffer_id, cx); + } else { + editor.fold_buffer(buffer_id, cx); + } + } + }) + .ok(); + }); + } + } + fn fold_at_level( &mut self, fold_at: &FoldAtLevel, @@ -17948,7 +18068,7 @@ impl Editor { parent: cx.weak_entity(), }, self.buffer.clone(), - self.project.clone(), + None, Some(self.display_map.clone()), window, cx, @@ -19602,8 +19722,9 @@ impl Editor { Anchor::in_buffer(excerpt_id, buffer_id, hint.position), hint.text(), ); - - new_inlays.push(inlay); + if !inlay.text.chars().contains(&'\n') { + new_inlays.push(inlay); + } }); } @@ -19831,17 +19952,16 @@ impl Editor { } fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { - let new_severity = if self.diagnostics_enabled() { - EditorSettings::get_global(cx) + if self.diagnostics_enabled() { + let new_severity = EditorSettings::get_global(cx) .diagnostics_max_severity - .unwrap_or(DiagnosticSeverity::Hint) - } else { - DiagnosticSeverity::Off - }; - self.set_max_diagnostics_severity(new_severity, cx); + .unwrap_or(DiagnosticSeverity::Hint); + self.set_max_diagnostics_severity(new_severity, cx); + } self.tasks_update_task = Some(self.refresh_runnables(window, cx)); self.update_edit_prediction_settings(cx); self.refresh_inline_completion(true, false, window, cx); + self.refresh_inline_values(cx); self.refresh_inlay_hints( InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), @@ -19859,7 +19979,6 @@ impl Editor { self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default(); - self.drag_and_drop_selection_enabled = editor_settings.drag_and_drop_selection; } if old_cursor_shape != self.cursor_shape { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index d7b8bac359abe21ef3cc977518e828899a57e299..5d8379ddfb87600f7cd56d10f5684ed333589e78 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -52,7 +52,7 @@ pub struct EditorSettings { #[serde(default)] pub diagnostics_max_severity: Option, pub inline_code_actions: bool, - pub drag_and_drop_selection: bool, + pub drag_and_drop_selection: DragAndDropSelection, pub lsp_document_colors: DocumentColorsRenderMode, } @@ -275,6 +275,26 @@ pub struct ScrollbarAxes { pub vertical: bool, } +/// Whether to allow drag and drop text selection in buffer. +#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, 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 @@ -536,10 +556,8 @@ pub struct EditorSettingsContent { /// Default: true pub inline_code_actions: Option, - /// Whether to allow drag and drop text selection in buffer. - /// - /// Default: true - pub drag_and_drop_selection: Option, + /// Drag and drop related settings + pub drag_and_drop_selection: Option, /// How to render LSP `textDocument/documentColor` colors in the editor. /// diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8615ff2a97de676b593e46d15811ad43f1a93706..43c9c0db659210f68c59e225f1103405c2f14dc1 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3080,6 +3080,45 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let lua_language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["--".into()], + block_comment: Some(("--[[".into(), "]]".into())), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(lua_language), cx)); + + // Line with line comment should extend + cx.set_state(indoc! {" + --ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + -- + --ˇ + "}); + + // Line with block comment that matches line comment should not extend + cx.set_state(indoc! {" + --[[ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + --[[ + ˇ + "}); +} + #[gpui::test] fn test_insert_with_old_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -3468,6 +3507,70 @@ async fn test_indent_outdent(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_indent_yaml_comments_with_multiple_cursors(cx: &mut TestAppContext) { + // This is a regression test for issue #33761 + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + cx.set_state( + r#"ˇ# ingress: +ˇ# api: +ˇ# enabled: false +ˇ# pathType: Prefix +ˇ# console: +ˇ# enabled: false +ˇ# pathType: Prefix +"#, + ); + + // Press tab to indent all lines + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + + cx.assert_editor_state( + r#" ˇ# ingress: + ˇ# api: + ˇ# enabled: false + ˇ# pathType: Prefix + ˇ# console: + ˇ# enabled: false + ˇ# pathType: Prefix +"#, + ); +} + +#[gpui::test] +async fn test_indent_yaml_non_comments_with_multiple_cursors(cx: &mut TestAppContext) { + // This is a test to make sure our fix for issue #33761 didn't break anything + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + cx.set_state( + r#"ˇingress: +ˇ api: +ˇ enabled: false +ˇ pathType: Prefix +"#, + ); + + // Press tab to indent all lines + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + + cx.assert_editor_state( + r#"ˇingress: + ˇapi: + ˇenabled: false + ˇpathType: Prefix +"#, + ); +} + #[gpui::test] async fn test_indent_outdent_with_hard_tabs(cx: &mut TestAppContext) { init_test(cx, |settings| { @@ -4011,6 +4114,29 @@ async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppC Zˇ» "}); + // Test sort_lines_by_length() + // + // Demonstrates: + // - ∞ is 3 bytes UTF-8, but sorted by its char count (1) + // - sort is stable + cx.set_state(indoc! {" + «123 + æ + 12 + ∞ + 1 + æˇ» + "}); + cx.update_editor(|e, window, cx| e.sort_lines_by_length(&SortLinesByLength, window, cx)); + cx.assert_editor_state(indoc! {" + «æ + ∞ + 1 + æ + 12 + 123ˇ» + "}); + // Test reverse_lines() cx.set_state(indoc! {" «5 @@ -4955,7 +5081,6 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { height: Some(1), render: Arc::new(|_| div().into_any()), priority: 0, - render_in_minimap: true, }], Some(Autoscroll::fit()), cx, @@ -4998,7 +5123,6 @@ async fn test_selections_and_replace_blocks(cx: &mut TestAppContext) { style: BlockStyle::Sticky, render: Arc::new(|_| gpui::div().into_any_element()), priority: 0, - render_in_minimap: true, }], None, cx, @@ -14610,7 +14734,7 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu executor.run_until_parked(); cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" @@ -14619,7 +14743,7 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu "}); cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" @@ -14628,7 +14752,7 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu "}); cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" @@ -14637,7 +14761,7 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu "}); cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); }); cx.assert_editor_state(indoc! {" @@ -21339,7 +21463,7 @@ println!("5"); .unwrap(); pane_1 .update_in(cx, |pane, window, cx| { - pane.close_inactive_items(&CloseInactiveItems::default(), window, cx) + pane.close_inactive_items(&CloseInactiveItems::default(), None, window, cx) }) .await .unwrap(); @@ -21375,7 +21499,7 @@ println!("5"); .unwrap(); pane_2 .update_in(cx, |pane, window, cx| { - pane.close_inactive_items(&CloseInactiveItems::default(), window, cx) + pane.close_inactive_items(&CloseInactiveItems::default(), None, window, cx) }) .await .unwrap(); @@ -22261,6 +22385,19 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { def f() -> list[str]: aˇ "}); + + // test does not outdent on typing : after case keyword + cx.set_state(indoc! {" + match 1: + caseˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input(":", window, cx); + }); + cx.assert_editor_state(indoc! {" + match 1: + case:ˇ + "}); } #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a4b2ceb5de5132ffda00f2be444205c77aac57a8..06fb52cdb3a63baa16f932ae0062b290016657ef 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -9,7 +9,7 @@ use crate::{ LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SoftWrap, StickyHeaderExcerpt, ToPoint, - ToggleFold, + ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, @@ -87,7 +87,6 @@ use util::{RangeExt, ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; -const SELECTION_DRAG_DELAY: Duration = Duration::from_millis(300); /// Determines what kinds of highlights should be applied to a lines background. #[derive(Clone, Copy, Default)] @@ -225,6 +224,7 @@ impl EditorElement { register_action(editor, window, Editor::autoindent); register_action(editor, window, Editor::delete_line); register_action(editor, window, Editor::join_lines); + register_action(editor, window, Editor::sort_lines_by_length); register_action(editor, window, Editor::sort_lines_case_sensitive); register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); @@ -416,6 +416,7 @@ impl EditorElement { register_action(editor, window, Editor::fold_recursive); register_action(editor, window, Editor::toggle_fold); register_action(editor, window, Editor::toggle_fold_recursive); + register_action(editor, window, Editor::toggle_fold_all); register_action(editor, window, Editor::unfold_lines); register_action(editor, window, Editor::unfold_recursive); register_action(editor, window, Editor::unfold_all); @@ -643,7 +644,11 @@ impl EditorElement { return; } - if editor.drag_and_drop_selection_enabled && click_count == 1 { + if EditorSettings::get_global(cx) + .drag_and_drop_selection + .enabled + && click_count == 1 + { let newest_anchor = editor.selections.newest_anchor(); let snapshot = editor.snapshot(window, cx); let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); @@ -1021,7 +1026,10 @@ impl EditorElement { ref click_position, ref mouse_down_time, } => { - if mouse_down_time.elapsed() >= SELECTION_DRAG_DELAY { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx).drag_and_drop_selection.delay, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { let drop_cursor = Selection { id: post_inc(&mut editor.selections.next_selection_id), start: drop_anchor, @@ -1610,6 +1618,7 @@ impl EditorElement { strikethrough: None, underline: None, }], + None, ) }) } else { @@ -1875,7 +1884,7 @@ impl EditorElement { let mut minimap = div() .size_full() - .shadow_sm() + .shadow_xs() .px(PADDING_OFFSET) .child(minimap_editor) .into_any_element(); @@ -2085,16 +2094,19 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> HashMap { - if self.editor.read(cx).mode().is_minimap() { - return HashMap::default(); - } - - let max_severity = match ProjectSettings::get_global(cx) - .diagnostics - .inline - .max_severity - .unwrap_or_else(|| self.editor.read(cx).diagnostics_max_severity) - .into_lsp() + let max_severity = match self + .editor + .read(cx) + .inline_diagnostics_enabled() + .then(|| { + ProjectSettings::get_global(cx) + .diagnostics + .inline + .max_severity + .unwrap_or_else(|| self.editor.read(cx).diagnostics_max_severity) + .into_lsp() + }) + .flatten() { Some(max_severity) => max_severity, None => return HashMap::default(), @@ -2444,7 +2456,7 @@ impl EditorElement { .git .inline_blame .and_then(|settings| settings.min_column) - .map(|col| self.column_pixels(col as usize, window, cx)) + .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; @@ -2610,9 +2622,6 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Option> { - if self.editor.read(cx).mode().is_minimap() { - return None; - } let indent_guides = self.editor.update(cx, |editor, cx| { editor.indent_guides(visible_buffer_range, snapshot, cx) })?; @@ -2629,7 +2638,7 @@ impl EditorElement { .enumerate() .filter_map(|(i, indent_guide)| { let single_indent_width = - self.column_pixels(indent_guide.tab_size as usize, window, cx); + 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; if start_x >= text_origin.x { @@ -2657,6 +2666,39 @@ impl EditorElement { ) } + fn layout_wrap_guides( + &self, + em_advance: Pixels, + scroll_position: gpui::Point, + content_origin: gpui::Point, + scrollbar_layout: Option<&EditorScrollbars>, + vertical_scrollbar_width: Pixels, + hitbox: &Hitbox, + window: &Window, + cx: &App, + ) -> SmallVec<[(Pixels, bool); 2]> { + let scroll_left = scroll_position.x * em_advance; + let content_origin = content_origin.x; + let horizontal_offset = content_origin - scroll_left; + let vertical_scrollbar_width = scrollbar_layout + .and_then(|layout| layout.visible.then_some(vertical_scrollbar_width)) + .unwrap_or_default(); + + self.editor + .read(cx) + .wrap_guides(cx) + .into_iter() + .flat_map(|(guide, active)| { + let wrap_position = self.column_pixels(guide, window); + let wrap_guide_x = wrap_position + horizontal_offset; + let display_wrap_guide = wrap_guide_x >= content_origin + && wrap_guide_x <= hitbox.bounds.right() - vertical_scrollbar_width; + + display_wrap_guide.then_some((wrap_guide_x, active)) + }) + .collect() + } + fn calculate_indent_guide_bounds( row_range: Range, line_height: Pixels, @@ -2795,6 +2837,7 @@ impl EditorElement { ) -> Vec { self.editor.update(cx, |editor, cx| { let active_task_indicator_row = + // TODO: add edit button on the right side of each row in the context menu if let Some(crate::CodeContextMenu::CodeActions(CodeActionsMenu { deployed_from, actions, @@ -3042,9 +3085,9 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Arc> { - let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| { - EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode.is_full() - }); + let include_line_numbers = snapshot + .show_line_numbers + .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); if !include_line_numbers { return Arc::default(); } @@ -3228,10 +3271,12 @@ impl EditorElement { underline: None, strikethrough: None, }; - let line = - window - .text_system() - .shape_line(line.to_string().into(), font_size, &[run]); + let line = window.text_system().shape_line( + line.to_string().into(), + font_size, + &[run], + None, + ); LineWithInvisibles { width: line.width, len: line.len, @@ -3355,22 +3400,18 @@ impl EditorElement { div() .size_full() - .children( - (!snapshot.mode.is_minimap() || custom.render_in_minimap).then(|| { - custom.render(&mut BlockContext { - window, - app: cx, - anchor_x, - margins: editor_margins, - line_height, - em_width, - block_id, - selected, - max_width: text_hitbox.size.width.max(*scroll_width), - editor_style: &self.style, - }) - }), - ) + .child(custom.render(&mut BlockContext { + window, + app: cx, + anchor_x, + margins: editor_margins, + line_height, + em_width, + block_id, + selected, + max_width: text_hitbox.size.width.max(*scroll_width), + editor_style: &self.style, + })) .into_any() } @@ -3576,24 +3617,37 @@ impl EditorElement { .tooltip({ let focus_handle = focus_handle.clone(); move |window, cx| { - Tooltip::for_action_in( + Tooltip::with_meta_in( "Toggle Excerpt Fold", - &ToggleFold, + Some(&ToggleFold), + "Alt+click to toggle all", &focus_handle, window, cx, ) } }) - .on_click(move |_, _, cx| { - if is_folded { + .on_click(move |event, window, cx| { + if event.modifiers().alt { + // Alt+click toggles all buffers editor.update(cx, |editor, cx| { - editor.unfold_buffer(buffer_id, cx); + editor.toggle_fold_all( + &ToggleFoldAll, + window, + cx, + ); }); } else { - editor.update(cx, |editor, cx| { - editor.fold_buffer(buffer_id, cx); - }); + // Regular click toggles single buffer + if is_folded { + editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_id, cx); + }); + } else { + editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_id, cx); + }); + } } }), ), @@ -5240,26 +5294,7 @@ impl EditorElement { paint_highlight(range.start, range.end, color, edges); } - let scroll_left = - layout.position_map.snapshot.scroll_position().x * layout.position_map.em_width; - - for (wrap_position, active) in layout.wrap_guides.iter() { - let x = (layout.position_map.text_hitbox.origin.x - + *wrap_position - + layout.position_map.em_width / 2.) - - scroll_left; - - let show_scrollbars = layout - .scrollbars_layout - .as_ref() - .map_or(false, |layout| layout.visible); - - if x < layout.position_map.text_hitbox.origin.x - || (show_scrollbars && x > self.scrollbar_left(&layout.hitbox.bounds)) - { - continue; - } - + for (guide_x, active) in layout.wrap_guides.iter() { let color = if *active { cx.theme().colors().editor_active_wrap_guide } else { @@ -5267,7 +5302,7 @@ impl EditorElement { }; window.paint_quad(fill( Bounds { - origin: point(x, layout.position_map.text_hitbox.origin.y), + origin: point(*guide_x, layout.position_map.text_hitbox.origin.y), size: size(px(1.), layout.position_map.text_hitbox.size.height), }, color, @@ -5691,6 +5726,19 @@ impl EditorElement { let editor = self.editor.read(cx); if editor.mouse_cursor_hidden { window.set_window_cursor_style(CursorStyle::None); + } else if let SelectionDragState::ReadyToDrag { + mouse_down_time, .. + } = &editor.selection_drag_state + { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx).drag_and_drop_selection.delay, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { + window.set_cursor_style( + CursorStyle::DragCopy, + &layout.position_map.text_hitbox, + ); + } } else if matches!( editor.selection_drag_state, SelectionDragState::Dragging { .. } @@ -6678,7 +6726,7 @@ impl EditorElement { let position_map: &PositionMap = &position_map; let line_height = position_map.line_height; - let max_glyph_width = position_map.em_width; + let max_glyph_advance = position_map.em_advance; let (delta, axis) = match delta { gpui::ScrollDelta::Pixels(mut pixels) => { //Trackpad @@ -6689,15 +6737,15 @@ impl EditorElement { gpui::ScrollDelta::Lines(lines) => { //Not trackpad let pixels = - point(lines.x * max_glyph_width, lines.y * line_height); + point(lines.x * max_glyph_advance, lines.y * line_height); (pixels, None) } }; let current_scroll_position = position_map.snapshot.scroll_position(); - let x = (current_scroll_position.x * max_glyph_width + let x = (current_scroll_position.x * max_glyph_advance - (delta.x * scroll_sensitivity)) - / max_glyph_width; + / max_glyph_advance; let y = (current_scroll_position.y * line_height - (delta.y * scroll_sensitivity)) / line_height; @@ -6724,7 +6772,7 @@ impl EditorElement { } fn paint_mouse_listeners(&mut self, layout: &EditorLayout, window: &mut Window, cx: &mut App) { - if self.editor.read(cx).mode.is_minimap() { + if layout.mode.is_minimap() { return; } @@ -6858,11 +6906,7 @@ impl EditorElement { }); } - fn scrollbar_left(&self, bounds: &Bounds) -> Pixels { - bounds.top_right().x - self.style.scrollbar_width - } - - fn column_pixels(&self, column: usize, window: &mut Window, _: &mut App) -> Pixels { + fn column_pixels(&self, column: usize, window: &Window) -> Pixels { let style = &self.style; let font_size = style.text.font_size.to_pixels(window.rem_size()); let layout = window.text_system().shape_line( @@ -6876,19 +6920,15 @@ impl EditorElement { underline: None, strikethrough: None, }], + None, ); layout.width } - fn max_line_number_width( - &self, - snapshot: &EditorSnapshot, - window: &mut Window, - cx: &mut App, - ) -> Pixels { + fn max_line_number_width(&self, snapshot: &EditorSnapshot, window: &mut Window) -> Pixels { let digit_count = snapshot.widest_line_number().ilog10() + 1; - self.column_pixels(digit_count as usize, window, cx) + self.column_pixels(digit_count as usize, window) } fn shape_line_number( @@ -6909,6 +6949,7 @@ impl EditorElement { text, self.style.text.font_size.to_pixels(window.rem_size()), &[run], + None, ) } @@ -7177,10 +7218,12 @@ impl LineWithInvisibles { }]) { if let Some(replacement) = highlighted_chunk.replacement { if !line.is_empty() { - let shaped_line = - window - .text_system() - .shape_line(line.clone().into(), font_size, &styles); + let shaped_line = window.text_system().shape_line( + line.clone().into(), + font_size, + &styles, + None, + ); width += shaped_line.width; len += shaped_line.len; fragments.push(LineFragment::Text(shaped_line)); @@ -7200,6 +7243,7 @@ impl LineWithInvisibles { chunk, font_size, &[text_style.to_run(highlighted_chunk.text.len())], + None, ); AvailableSpace::Definite(shaped_line.width) } else { @@ -7244,7 +7288,7 @@ impl LineWithInvisibles { }; let line_layout = window .text_system() - .shape_line(x, font_size, &[run]) + .shape_line(x, font_size, &[run], None) .with_len(highlighted_chunk.text.len()); width += line_layout.width; @@ -7259,6 +7303,7 @@ impl LineWithInvisibles { line.clone().into(), font_size, &styles, + None, ); width += shaped_line.width; len += shaped_line.len; @@ -7789,7 +7834,7 @@ impl Element for EditorElement { } => { let editor_handle = cx.entity().clone(); let max_line_number_width = - self.max_line_number_width(&editor.snapshot(window, cx), window, cx); + self.max_line_number_width(&editor.snapshot(window, cx), window); window.request_measured_layout( Style::default(), move |known_dimensions, available_space, window, cx| { @@ -7854,9 +7899,14 @@ impl Element for EditorElement { line_height: Some(self.style.text.line_height), ..Default::default() }; - let focus_handle = self.editor.focus_handle(cx); - window.set_view_id(self.editor.entity_id()); - window.set_focus_handle(&focus_handle, cx); + + let is_minimap = self.editor.read(cx).mode.is_minimap(); + + if !is_minimap { + let focus_handle = self.editor.focus_handle(cx); + window.set_view_id(self.editor.entity_id()); + window.set_focus_handle(&focus_handle, cx); + } let rem_size = self.rem_size(cx); window.with_rem_size(rem_size, |window| { @@ -7879,7 +7929,7 @@ impl Element for EditorElement { .gutter_dimensions( font_id, font_size, - self.max_line_number_width(&snapshot, window, cx), + self.max_line_number_width(&snapshot, window), cx, ) .or_else(|| { @@ -7928,6 +7978,7 @@ impl Element for EditorElement { 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_content_width / em_advance); if matches!( editor.mode, @@ -7954,14 +8005,6 @@ impl Element for EditorElement { } }); - let wrap_guides = self - .editor - .read(cx) - .wrap_guides(cx) - .iter() - .map(|(guide, active)| (self.column_pixels(*guide, window, cx), *active)) - .collect::>(); - let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let gutter_hitbox = window.insert_hitbox( gutter_bounds(bounds, gutter_dimensions), @@ -8007,23 +8050,25 @@ impl Element for EditorElement { } }; - // TODO: Autoscrolling for both axes - let mut autoscroll_request = None; - let mut autoscroll_containing_element = false; - let mut autoscroll_horizontally = false; - self.editor.update(cx, |editor, cx| { - autoscroll_request = editor.autoscroll_request(); - autoscroll_containing_element = + let ( + autoscroll_request, + autoscroll_containing_element, + needs_horizontal_autoscroll, + ) = self.editor.update(cx, |editor, cx| { + let autoscroll_request = editor.autoscroll_request(); + let autoscroll_containing_element = autoscroll_request.is_some() || editor.has_pending_selection(); - // TODO: Is this horizontal or vertical?! - autoscroll_horizontally = editor.autoscroll_vertically( - bounds, - line_height, - max_scroll_top, - window, - cx, - ); - snapshot = editor.snapshot(window, cx); + + let (needs_horizontal_autoscroll, was_scrolled) = editor + .autoscroll_vertically(bounds, line_height, max_scroll_top, window, cx); + if was_scrolled.0 { + snapshot = editor.snapshot(window, cx); + } + ( + autoscroll_request, + autoscroll_containing_element, + needs_horizontal_autoscroll, + ) }); let mut scroll_position = snapshot.scroll_position(); @@ -8299,18 +8344,22 @@ impl Element for EditorElement { window, cx, ); - let new_renrerer_widths = line_layouts - .iter() - .flat_map(|layout| &layout.fragments) - .filter_map(|fragment| { - if let LineFragment::Element { id, size, .. } = fragment { - Some((*id, size.width)) - } else { - None - } - }); - if self.editor.update(cx, |editor, cx| { - editor.update_renderer_widths(new_renrerer_widths, cx) + let new_renderer_widths = (!is_minimap).then(|| { + line_layouts + .iter() + .flat_map(|layout| &layout.fragments) + .filter_map(|fragment| { + if let LineFragment::Element { id, size, .. } = fragment { + Some((*id, size.width)) + } else { + None + } + }) + }); + if new_renderer_widths.is_some_and(|new_renderer_widths| { + self.editor.update(cx, |editor, cx| { + editor.update_renderer_widths(new_renderer_widths, cx) + }) }) { // If the fold widths have changed, we need to prepaint // the element again to account for any changes in @@ -8373,27 +8422,31 @@ impl Element for EditorElement { let sticky_header_excerpt_id = sticky_header_excerpt.as_ref().map(|top| top.excerpt.id); - let blocks = window.with_element_namespace("blocks", |window| { - self.render_blocks( - start_row..end_row, - &snapshot, - &hitbox, - &text_hitbox, - editor_width, - &mut scroll_width, - &editor_margins, - em_width, - gutter_dimensions.full_width(), - line_height, - &mut line_layouts, - &local_selections, - &selected_buffer_ids, - is_row_soft_wrapped, - sticky_header_excerpt_id, - window, - cx, - ) - }); + let blocks = (!is_minimap) + .then(|| { + window.with_element_namespace("blocks", |window| { + self.render_blocks( + start_row..end_row, + &snapshot, + &hitbox, + &text_hitbox, + editor_width, + &mut scroll_width, + &editor_margins, + em_width, + gutter_dimensions.full_width(), + line_height, + &mut line_layouts, + &local_selections, + &selected_buffer_ids, + is_row_soft_wrapped, + sticky_header_excerpt_id, + window, + cx, + ) + }) + }) + .unwrap_or_else(|| Ok((Vec::default(), HashMap::default()))); let (mut blocks, row_block_types) = match blocks { Ok(blocks) => blocks, Err(resized_blocks) => { @@ -8432,24 +8485,22 @@ impl Element for EditorElement { ); self.editor.update(cx, |editor, cx| { - let clamped = editor.scroll_manager.clamp_scroll_left(scroll_max.x); + if editor.scroll_manager.clamp_scroll_left(scroll_max.x) { + scroll_position.x = scroll_position.x.min(scroll_max.x); + } - let autoscrolled = if autoscroll_horizontally { - editor.autoscroll_horizontally( + if needs_horizontal_autoscroll.0 + && let Some(new_scroll_position) = editor.autoscroll_horizontally( start_row, editor_content_width, scroll_width, em_advance, &line_layouts, + window, cx, ) - } else { - false - }; - - if clamped || autoscrolled { - snapshot = editor.snapshot(window, cx); - scroll_position = snapshot.scroll_position(); + { + scroll_position = new_scroll_position; } }); @@ -8564,7 +8615,9 @@ impl Element for EditorElement { } } else { log::error!( - "bug: line_ix {} is out of bounds - row_infos.len(): {}, line_layouts.len(): {}, crease_trailers.len(): {}", + "bug: line_ix {} is out of bounds - row_infos.len(): {}, \ + line_layouts.len(): {}, \ + crease_trailers.len(): {}", line_ix, row_infos.len(), line_layouts.len(), @@ -8585,28 +8638,6 @@ impl Element for EditorElement { cx, ); - self.editor.update(cx, |editor, cx| { - let clamped = editor.scroll_manager.clamp_scroll_left(scroll_max.x); - - let autoscrolled = if autoscroll_horizontally { - editor.autoscroll_horizontally( - start_row, - editor_content_width, - scroll_width, - em_width, - &line_layouts, - cx, - ) - } else { - false - }; - - if clamped || autoscrolled { - snapshot = editor.snapshot(window, cx); - scroll_position = snapshot.scroll_position(); - } - }); - let line_elements = self.prepaint_lines( start_row, &mut line_layouts, @@ -8797,6 +8828,17 @@ impl Element for EditorElement { self.prepaint_expand_toggles(&mut expand_toggles, window, cx) }); + let wrap_guides = self.layout_wrap_guides( + em_advance, + scroll_position, + content_origin, + scrollbars_layout.as_ref(), + vertical_scrollbar_width, + &hitbox, + window, + cx, + ); + let minimap = window.with_element_namespace("minimap", |window| { self.layout_minimap( &snapshot, @@ -8821,6 +8863,7 @@ impl Element for EditorElement { underline: None, strikethrough: None, }], + None, ); let space_invisible = window.text_system().shape_line( "•".into(), @@ -8833,6 +8876,7 @@ impl Element for EditorElement { underline: None, strikethrough: None, }], + None, ); let mode = snapshot.mode.clone(); @@ -8934,19 +8978,21 @@ impl Element for EditorElement { window: &mut Window, cx: &mut App, ) { - let focus_handle = self.editor.focus_handle(cx); - let key_context = self - .editor - .update(cx, |editor, cx| editor.key_context(window, cx)); - - window.set_key_context(key_context); - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.editor.clone()), - cx, - ); - self.register_actions(window, cx); - self.register_key_listeners(window, cx, layout); + if !layout.mode.is_minimap() { + let focus_handle = self.editor.focus_handle(cx); + let key_context = self + .editor + .update(cx, |editor, cx| editor.key_context(window, cx)); + + window.set_key_context(key_context); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.editor.clone()), + cx, + ); + self.register_actions(window, cx); + self.register_key_listeners(window, cx, layout); + } let text_style = TextStyleRefinement { font_size: Some(self.style.text.font_size), @@ -10255,7 +10301,6 @@ mod tests { height: Some(3), render: Arc::new(|cx| div().h(3. * cx.window.line_height()).into_any()), priority: 0, - render_in_minimap: true, }], None, cx, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index cae47895356c4fbd6ffc94779952475ce6f18dd6..bda229e34669482549182b2c7abbe2c3efb9a751 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -381,10 +381,14 @@ fn show_hover( .anchor_after(local_diagnostic.range.end), }; + let scroll_handle = ScrollHandle::new(); + Some(DiagnosticPopover { local_diagnostic, markdown, border_color, + scrollbar_state: ScrollbarState::new(scroll_handle.clone()), + scroll_handle, background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, @@ -955,6 +959,8 @@ pub struct DiagnosticPopover { pub keyboard_grace: Rc>, pub anchor: Anchor, _subscription: Subscription, + pub scroll_handle: ScrollHandle, + pub scrollbar_state: ScrollbarState, } impl DiagnosticPopover { @@ -968,10 +974,7 @@ impl DiagnosticPopover { let this = cx.entity().downgrade(); div() .id("diagnostic") - .block() - .max_h(max_size.height) - .overflow_y_scroll() - .max_w(max_size.width) + .occlude() .elevation_2_borderless(cx) // Don't draw the background color if the theme // allows transparent surfaces. @@ -992,27 +995,72 @@ impl DiagnosticPopover { div() .py_1() .px_2() - .child( - MarkdownElement::new( - self.markdown.clone(), - diagnostics_markdown_style(window, cx), - ) - .on_url_click(move |link, window, cx| { - if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) { - this.update(cx, |this, cx| { - renderer.as_ref().open_link(this, link, window, cx); - }) - .ok(); - } - }), - ) .bg(self.background_color) .border_1() .border_color(self.border_color) - .rounded_lg(), + .rounded_lg() + .child( + div() + .id("diagnostic-content-container") + .overflow_y_scroll() + .max_w(max_size.width) + .max_h(max_size.height) + .track_scroll(&self.scroll_handle) + .child( + MarkdownElement::new( + self.markdown.clone(), + diagnostics_markdown_style(window, cx), + ) + .on_url_click( + move |link, window, cx| { + if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) + { + this.update(cx, |this, cx| { + renderer.as_ref().open_link(this, link, window, cx); + }) + .ok(); + } + }, + ), + ), + ) + .child(self.render_vertical_scrollbar(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)] diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index fa6bd93ab8558628670cb315e672ddf4fb3ebcab..ca635a2132790e809258c8bd63fbd3a1c3edcdb3 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -813,7 +813,13 @@ impl Item for Editor { window: &mut Window, cx: &mut Context, ) -> Task> { - self.report_editor_event("Editor Saved", None, cx); + // Add meta data tracking # of auto saves + if options.autosave { + self.report_editor_event("Editor Autosaved", None, cx); + } else { + self.report_editor_event("Editor Saved", None, cx); + } + let buffers = self.buffer().clone().read(cx).all_buffers(); let buffers = buffers .into_iter() @@ -1220,7 +1226,20 @@ impl SerializableItem for Editor { abs_path: None, contents: None, .. - } => Task::ready(Err(anyhow!("No path or contents found for buffer"))), + } => window.spawn(cx, async move |cx| { + let buffer = project + .update(cx, |project, cx| project.create_buffer(cx))? + .await?; + + cx.update(|window, cx| { + cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, Some(project), window, cx); + + editor.read_metadata_from_db(item_id, workspace_id, window, cx); + editor + }) + }) + }), } } @@ -1607,24 +1626,10 @@ impl SearchableItem for Editor { let text = self.buffer.read(cx); let text = text.snapshot(cx); let mut edits = vec![]; - let mut last_point: Option = None; for m in matches { - let point = m.start.to_point(&text); let text = text.text_for_range(m.clone()).collect::>(); - // Check if the row for the current match is different from the last - // match. If that's not the case and we're still replacing matches - // in the same row/line, skip this match if the `one_match_per_line` - // option is enabled. - if last_point.is_none() { - last_point = Some(point); - } else if last_point.is_some() && point.row != last_point.unwrap().row { - last_point = Some(point); - } else if query.one_match_per_line().is_some_and(|enabled| enabled) { - continue; - } - let text: Cow<_> = if text.len() == 1 { text.first().cloned().unwrap().into() } else { @@ -2106,5 +2111,38 @@ mod tests { assert!(editor.has_conflict(cx)); // The editor should have a conflict }); } + + // Test case 5: Deserialize with no path, no content, no language, and no old mtime (new, empty, unsaved buffer) + { + let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); + + let item_id = 10000 as ItemId; + let serialized_editor = SerializedEditor { + abs_path: None, + contents: None, + language: None, + mtime: None, + }; + + DB.save_serialized_editor(item_id, workspace_id, serialized_editor) + .await + .unwrap(); + + let deserialized = + deserialize_editor(item_id, workspace_id, workspace, project, cx).await; + + deserialized.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), ""); + assert!(!editor.is_dirty(cx)); + assert!(!editor.has_conflict(cx)); + + let buffer = editor.buffer().read(cx).as_singleton().unwrap().read(cx); + assert!(buffer.file().is_none()); + }); + } } } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 0642b2b20ebfb7213f74ab6980889a7e07218415..7310d6d3c05f4532f0a9ff5d27b86f0efdf24791 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -13,6 +13,7 @@ use crate::{ pub use autoscroll::{Autoscroll, AutoscrollStrategy}; use core::fmt::Debug; use gpui::{App, Axis, Context, Global, Pixels, Task, Window, point, px}; +use language::language_settings::{AllLanguageSettings, SoftWrap}; use language::{Bias, Point}; pub use scroll_amount::ScrollAmount; use settings::Settings; @@ -26,6 +27,8 @@ use workspace::{ItemId, WorkspaceId}; pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); +pub struct WasScrolled(pub(crate) bool); + #[derive(Default)] pub struct ScrollbarAutoHide(pub bool); @@ -151,12 +154,16 @@ pub struct ScrollManager { pub(crate) vertical_scroll_margin: f32, 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)>, show_scrollbars: bool, hide_scrollbar_task: Option>, active_scrollbar: Option, visible_line_count: Option, + visible_column_count: Option, forbid_vertical_scroll: bool, minimap_thumb_state: Option, } @@ -173,6 +180,7 @@ impl ScrollManager { active_scrollbar: None, last_autoscroll: None, visible_line_count: None, + visible_column_count: None, forbid_vertical_scroll: false, minimap_thumb_state: None, } @@ -209,66 +217,56 @@ impl ScrollManager { workspace_id: Option, window: &mut Window, cx: &mut Context, - ) { - let (new_anchor, top_row) = if scroll_position.y <= 0. { - ( - ScrollAnchor { - anchor: Anchor::min(), - offset: scroll_position.max(&gpui::Point::default()), - }, - 0, - ) - } else { - let scroll_top = scroll_position.y; - let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line { - 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; - scroll_top.min(max_row - height_in_lines + 1.).max(0.) - } else { - scroll_top - } + ) -> WasScrolled { + let scroll_top = scroll_position.y.max(0.); + let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line { + 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; + scroll_top.min(max_row - height_in_lines + 1.).max(0.) + } else { + scroll_top } - ScrollBeyondLastLine::VerticalScrollMargin => { - if let Some(height_in_lines) = self.visible_line_count { - let max_row = map.max_point().row().0 as f32; - scroll_top - .min(max_row - height_in_lines + 1. + self.vertical_scroll_margin) - .max(0.) - } else { - scroll_top - } + } + ScrollBeyondLastLine::VerticalScrollMargin => { + if let Some(height_in_lines) = self.visible_line_count { + let max_row = map.max_point().row().0 as f32; + scroll_top + .min(max_row - height_in_lines + 1. + self.vertical_scroll_margin) + .max(0.) + } else { + scroll_top } - }; + } + }; - let scroll_top_buffer_point = - DisplayPoint::new(DisplayRow(scroll_top as u32), 0).to_point(map); - let top_anchor = map - .buffer_snapshot - .anchor_at(scroll_top_buffer_point, Bias::Right); - - ( - ScrollAnchor { - anchor: top_anchor, - offset: point( - scroll_position.x.max(0.), - scroll_top - top_anchor.to_display_point(map).row().as_f32(), - ), - }, - scroll_top_buffer_point.row, + let scroll_top_row = DisplayRow(scroll_top as u32); + let scroll_top_buffer_point = map + .clip_point( + DisplayPoint::new(scroll_top_row, scroll_position.x as u32), + Bias::Left, ) - }; + .to_point(map); + let top_anchor = map + .buffer_snapshot + .anchor_at(scroll_top_buffer_point, Bias::Right); self.set_anchor( - new_anchor, - top_row, + ScrollAnchor { + anchor: top_anchor, + offset: point( + scroll_position.x.max(0.), + scroll_top - top_anchor.to_display_point(map).row().as_f32(), + ), + }, + scroll_top_buffer_point.row, local, autoscroll, workspace_id, window, cx, - ); + ) } fn set_anchor( @@ -280,7 +278,7 @@ impl ScrollManager { workspace_id: Option, window: &mut Window, cx: &mut Context, - ) { + ) -> WasScrolled { let adjusted_anchor = if self.forbid_vertical_scroll { ScrollAnchor { offset: gpui::Point::new(anchor.offset.x, self.anchor.offset.y), @@ -290,10 +288,14 @@ impl ScrollManager { anchor }; + self.autoscroll_request.take(); + if self.anchor == adjusted_anchor { + return WasScrolled(false); + } + self.anchor = adjusted_anchor; cx.emit(EditorEvent::ScrollPositionChanged { local, autoscroll }); self.show_scrollbars(window, cx); - self.autoscroll_request.take(); if let Some(workspace_id) = workspace_id { let item_id = cx.entity().entity_id().as_u64() as ItemId; @@ -315,6 +317,8 @@ impl ScrollManager { .detach() } cx.notify(); + + WasScrolled(true) } pub fn show_scrollbars(&mut self, window: &mut Window, cx: &mut Context) { @@ -476,6 +480,10 @@ impl Editor { .map(|line_count| line_count as u32 - 1) } + pub fn visible_column_count(&self) -> Option { + self.scroll_manager.visible_column_count + } + pub(crate) fn set_visible_line_count( &mut self, lines: f32, @@ -497,6 +505,10 @@ impl Editor { } } + pub(crate) fn set_visible_column_count(&mut self, columns: f32) { + self.scroll_manager.visible_column_count = Some(columns); + } + pub fn apply_scroll_delta( &mut self, scroll_delta: gpui::Point, @@ -517,13 +529,13 @@ impl Editor { scroll_position: gpui::Point, window: &mut Window, cx: &mut Context, - ) { + ) -> WasScrolled { let mut position = scroll_position; if self.scroll_manager.forbid_vertical_scroll { let current_position = self.scroll_position(cx); position.y = current_position.y; } - self.set_scroll_position_internal(position, true, false, window, cx); + self.set_scroll_position_internal(position, true, false, window, cx) } /// Scrolls so that `row` is at the top of the editor view. @@ -555,7 +567,7 @@ impl Editor { autoscroll: bool, window: &mut Window, cx: &mut Context, - ) { + ) -> WasScrolled { let map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); self.set_scroll_position_taking_display_map( scroll_position, @@ -564,7 +576,7 @@ impl Editor { map, window, cx, - ); + ) } fn set_scroll_position_taking_display_map( @@ -575,7 +587,7 @@ impl Editor { display_map: DisplaySnapshot, window: &mut Window, cx: &mut Context, - ) { + ) -> WasScrolled { hide_hover(self, cx); let workspace_id = self.workspace.as_ref().and_then(|workspace| workspace.1); @@ -589,7 +601,7 @@ impl Editor { scroll_position }; - self.scroll_manager.set_scroll_position( + let editor_was_scrolled = self.scroll_manager.set_scroll_position( adjusted_position, &display_map, local, @@ -601,6 +613,7 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); self.refresh_colors(false, None, window, cx); + editor_was_scrolled } pub fn scroll_position(&self, cx: &mut Context) -> gpui::Point { @@ -675,25 +688,48 @@ impl Editor { let Some(visible_line_count) = self.visible_line_count() else { return; }; + let Some(mut visible_column_count) = self.visible_column_count() else { + return; + }; + + // If the user has a preferred line length, and has the editor + // configured to wrap at the preferred line length, or bounded to it, + // use that value over the visible column count. This was mostly done so + // that tests could actually be written for vim's `z l`, `z h`, `z + // shift-l` and `z shift-h` commands, as there wasn't a good way to + // configure the editor to only display a certain number of columns. If + // that ever happens, this could probably be removed. + let settings = AllLanguageSettings::get_global(cx); + 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; + } + } // 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() > 0. { + 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; } } - let new_position = - current_position + point(amount.columns(), amount.lines(visible_line_count)); + let new_position = current_position + + point( + amount.columns(visible_column_count), + amount.lines(visible_line_count), + ); self.set_scroll_position(new_position, window, cx); } /// Returns an ordering. The newest selection is: /// Ordering::Equal => on screen - /// Ordering::Less => above the screen - /// Ordering::Greater => below the screen + /// Ordering::Less => above or to the left of the screen + /// Ordering::Greater => below or to the right of the screen pub fn newest_selection_on_screen(&self, cx: &mut App) -> Ordering { let snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let newest_head = self @@ -711,8 +747,12 @@ impl Editor { return Ordering::Less; } - if let Some(visible_lines) = self.visible_line_count() { - if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) { + if let (Some(visible_lines), Some(visible_columns)) = + (self.visible_line_count(), self.visible_column_count()) + { + 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; } } diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 55998aa2fd4081f1679ea603c0065fca08910b02..e8a1f8da734685f85091b3bd28a2fb1a0be89208 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -1,6 +1,6 @@ use crate::{ DisplayRow, Editor, EditorMode, LineWithInvisibles, RowExt, SelectionEffects, - display_map::ToDisplayPoint, + display_map::ToDisplayPoint, scroll::WasScrolled, }; use gpui::{Bounds, Context, Pixels, Window, px}; use language::Point; @@ -99,19 +99,21 @@ impl AutoscrollStrategy { } } +pub(crate) struct NeedsHorizontalAutoscroll(pub(crate) bool); + impl Editor { pub fn autoscroll_request(&self) -> Option { self.scroll_manager.autoscroll_request() } - pub fn autoscroll_vertically( + pub(crate) fn autoscroll_vertically( &mut self, bounds: Bounds, line_height: Pixels, max_scroll_top: f32, window: &mut Window, cx: &mut Context, - ) -> bool { + ) -> (NeedsHorizontalAutoscroll, WasScrolled) { let viewport_height = bounds.size.height; let visible_lines = viewport_height / line_height; let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -129,12 +131,14 @@ impl Editor { scroll_position.y = max_scroll_top; } - if original_y != scroll_position.y { - self.set_scroll_position(scroll_position, window, cx); - } + let editor_was_scrolled = if original_y != scroll_position.y { + self.set_scroll_position(scroll_position, window, cx) + } else { + WasScrolled(false) + }; let Some((autoscroll, local)) = self.scroll_manager.autoscroll_request.take() else { - return false; + return (NeedsHorizontalAutoscroll(false), editor_was_scrolled); }; let mut target_top; @@ -212,7 +216,7 @@ impl Editor { target_bottom = target_top + 1.; } - match strategy { + let was_autoscrolled = match strategy { AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => { let margin = margin.min(self.scroll_manager.vertical_scroll_margin); let target_top = (target_top - margin).max(0.0); @@ -225,39 +229,42 @@ impl Editor { if needs_scroll_up && !needs_scroll_down { scroll_position.y = target_top; - self.set_scroll_position_internal(scroll_position, local, true, window, cx); - } - if !needs_scroll_up && needs_scroll_down { + } else if !needs_scroll_up && needs_scroll_down { scroll_position.y = target_bottom - visible_lines; - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + } + + if needs_scroll_up ^ needs_scroll_down { + self.set_scroll_position_internal(scroll_position, local, true, window, cx) + } else { + WasScrolled(false) } } AutoscrollStrategy::Center => { scroll_position.y = (target_top - margin).max(0.0); - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::Focused => { let margin = margin.min(self.scroll_manager.vertical_scroll_margin); scroll_position.y = (target_top - margin).max(0.0); - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::Top => { scroll_position.y = (target_top).max(0.0); - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::Bottom => { scroll_position.y = (target_bottom - visible_lines).max(0.0); - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::TopRelative(lines) => { scroll_position.y = target_top - lines as f32; - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::BottomRelative(lines) => { scroll_position.y = target_bottom + lines as f32; - self.set_scroll_position_internal(scroll_position, local, true, window, cx); + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } - } + }; self.scroll_manager.last_autoscroll = Some(( self.scroll_manager.anchor.offset, @@ -266,7 +273,8 @@ impl Editor { strategy, )); - true + let was_scrolled = WasScrolled(editor_was_scrolled.0 || was_autoscrolled.0); + (NeedsHorizontalAutoscroll(true), was_scrolled) } pub(crate) fn autoscroll_horizontally( @@ -274,12 +282,14 @@ impl Editor { start_row: DisplayRow, viewport_width: Pixels, scroll_width: Pixels, - max_glyph_width: Pixels, + em_advance: Pixels, layouts: &[LineWithInvisibles], + window: &mut Window, cx: &mut Context, - ) -> bool { + ) -> Option> { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = self.selections.all::(cx); + let mut scroll_position = self.scroll_manager.scroll_position(&display_map); let mut target_left; let mut target_right; @@ -295,16 +305,17 @@ impl Editor { if head.row() >= start_row && head.row() < DisplayRow(start_row.0 + layouts.len() as u32) { - let start_column = head.column().saturating_sub(3); - let end_column = cmp::min(display_map.line_len(head.row()), head.column() + 3); + let start_column = head.column(); + let end_column = cmp::min(display_map.line_len(head.row()), head.column()); target_left = target_left.min( layouts[head.row().minus(start_row) as usize] - .x_for_index(start_column 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) - + max_glyph_width, + + em_advance, ); } } @@ -316,20 +327,26 @@ impl Editor { target_right = target_right.min(scroll_width); if target_right - target_left > viewport_width { - return false; + return None; } - let scroll_left = self.scroll_manager.anchor.offset.x * max_glyph_width; + let scroll_left = self.scroll_manager.anchor.offset.x * em_advance; let scroll_right = scroll_left + viewport_width; - if target_left < scroll_left { - self.scroll_manager.anchor.offset.x = target_left / max_glyph_width; - true + let was_scrolled = if target_left < scroll_left { + scroll_position.x = target_left / em_advance; + self.set_scroll_position_internal(scroll_position, true, true, window, cx) } else if target_right > scroll_right { - self.scroll_manager.anchor.offset.x = (target_right - viewport_width) / max_glyph_width; - true + scroll_position.x = (target_right - viewport_width) / em_advance; + self.set_scroll_position_internal(scroll_position, true, true, window, cx) + } else { + WasScrolled(false) + }; + + if was_scrolled.0 { + Some(scroll_position) } else { - false + None } } diff --git a/crates/editor/src/scroll/scroll_amount.rs b/crates/editor/src/scroll/scroll_amount.rs index bc9d4757f1d6b30192c5888e5a3d576ea34fec25..b2af4f8e4fbce899c6aee317402ee1365cee8600 100644 --- a/crates/editor/src/scroll/scroll_amount.rs +++ b/crates/editor/src/scroll/scroll_amount.rs @@ -23,6 +23,8 @@ pub enum ScrollAmount { Page(f32), // Scroll N columns (positive is towards the right of the document) Column(f32), + // Scroll N page width (positive is towards the right of the document) + PageWidth(f32), } impl ScrollAmount { @@ -37,14 +39,16 @@ impl ScrollAmount { (visible_line_count * count).trunc() } Self::Column(_count) => 0.0, + Self::PageWidth(_count) => 0.0, } } - pub fn columns(&self) -> f32 { + pub fn columns(&self, visible_column_count: f32) -> f32 { match self { Self::Line(_count) => 0.0, Self::Page(_count) => 0.0, Self::Column(count) => *count, + Self::PageWidth(count) => (visible_column_count * count).trunc(), } } @@ -58,6 +62,7 @@ impl ScrollAmount { // 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), } } diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 39121377bba907dbf38983156e1e0f55d187829a..a02b4a7f0bb7f306b7c5389336c6113a7d15d096 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -8,6 +8,7 @@ mod tool_metrics; use assertions::{AssertionsReport, display_error_row}; use instance::{ExampleInstance, JudgeOutput, RunOutput, run_git}; +use language_extension::LspAccess; pub(crate) use tool_metrics::*; use ::fs::RealFs; @@ -415,7 +416,11 @@ pub fn init(cx: &mut App) -> Arc { language::init(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); - language_extension::init(extension_host_proxy.clone(), languages.clone()); + language_extension::init( + LspAccess::Noop, + extension_host_proxy.clone(), + 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); diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs index 0e4f770a6757a216061e28efb227a339f1094084..7879ad6f2ebb782bd4a5620f0fdf562c9aad1360 100644 --- a/crates/eval/src/examples/file_change_notification.rs +++ b/crates/eval/src/examples/file_change_notification.rs @@ -14,7 +14,7 @@ impl Example for FileChangeNotificationExample { url: "https://github.com/octocat/hello-world".to_string(), revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(), language_server: None, - max_assertions: Some(1), + max_assertions: None, profile_id: AgentProfileId::default(), existing_thread_json: None, max_turns: Some(3), diff --git a/crates/eval/src/explorer.html b/crates/eval/src/explorer.html index fec459716393ef50ececca0dd456eec674f15edd..04c41090d37ef975ce1f4805cde3eaaf433d100a 100644 --- a/crates/eval/src/explorer.html +++ b/crates/eval/src/explorer.html @@ -324,20 +324,8 @@

Thread Explorer

- - + + @@ -368,8 +352,7 @@ ← Previous
- Thread 1 of - 1: + Thread 1 of 1: Default Thread